Shoraky commited on
Commit
82b44e7
·
1 Parent(s): e130b68

Improve backend request performance

Browse files
Dockerfile CHANGED
@@ -17,7 +17,7 @@ COPY requirements.txt ./
17
  RUN pip install --no-cache-dir -r requirements.txt
18
  COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
19
  COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
20
- COPY --chown=appuser:appuser package.json app.py start.py ./
21
  USER appuser
22
  EXPOSE 7860
23
  CMD ["python", "start.py"]
 
17
  RUN pip install --no-cache-dir -r requirements.txt
18
  COPY --from=node-build --chown=appuser:appuser /app/dist ./dist
19
  COPY --from=node-build --chown=appuser:appuser /app/node_modules ./node_modules
20
+ COPY --chown=appuser:appuser package.json app.py start.py storage_listing.py ./
21
  USER appuser
22
  EXPOSE 7860
23
  CMD ["python", "start.py"]
PERFORMANCE.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Performance checks
2
+
3
+ The API includes `Server-Timing` on JSON responses:
4
+
5
+ - `app`: server time until JSON serialization starts.
6
+ - `supabase`: summed response-header waits for Supabase requests started in this request, including auth. Parallel waits overlap, and shared in-flight work is counted only on the originating request.
7
+
8
+ Requests taking at least one second emit `slow_request` with a route template, status, elapsed time and upstream counts. No cookies, bodies, query parameters or user IDs are logged by this instrumentation. Streaming transfer time is not included in the JSON header.
9
+
10
+ Compare browser Network timings against these headers after deploying. High browser waiting time with low `app` time points toward transport/proxy latency; high `supabase` times identify upstream waits. Inspect authenticated list, detail, update and delete requests separately.
11
+
12
+ Read-only baseline from the development machine on 2026-10-20 (two requests per endpoint, not an authenticated benchmark):
13
+
14
+ | Endpoint | First | Second |
15
+ | --- | ---: | ---: |
16
+ | Public Space `/health` | 1006 ms | 787 ms |
17
+ | Public Space `/api/subscriptions` | 921 ms | 613 ms |
18
+ | Pages proxy `/api/subscriptions` | 1258 ms | 703 ms |
19
+
20
+ These samples include the development machine's network and do not establish a hosting bottleneck or a before/after improvement.
21
+
22
+ Changes reduce repeated profile reads after mutations, avoid writes on ordinary profile reads, coalesce concurrent authentication/role checks without caching completed permission decisions, and scope storage listings to one session. The storage listing APIs are documented at https://huggingface.co/docs/huggingface_hub/package_reference/hf_api.
23
+
24
+ The frontend keeps bounded, account-keyed summary snapshots in tab session storage for at most five minutes. Reloads display them while revalidating. Full analysis payloads are excluded; logout/account changes clear the cache. Server authorization remains required for every API request.
25
+
26
+ Remaining scaling work should follow production measurements: paginate large rosters and session lists, aggregate ratings in the database, and evaluate backend/database region placement. No production infrastructure or database changes were made here.
app.py CHANGED
@@ -30,6 +30,7 @@ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
30
  from fastapi.responses import FileResponse, JSONResponse
31
  from huggingface_hub import HfApi, hf_hub_download, snapshot_download
32
  from huggingface_hub.errors import RemoteEntryNotFoundError
 
33
 
34
  load_dotenv(Path(__file__).with_name(".env"))
35
  jobs = {}
@@ -862,9 +863,7 @@ def delete_session(session_id: UUID, request: Request):
862
  raise HTTPException(409, "Session uses a different storage layout")
863
 
864
  if storage_path == expected_path:
865
- prefix = f"{storage_path}/"
866
- existing_files = hub().list_repo_files(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset")
867
- if any(file_path.startswith(prefix) for file_path in existing_files):
868
  hub().delete_folder(
869
  repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
870
  path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
@@ -884,7 +883,7 @@ def results(session_id: UUID, request: Request):
884
  prefix = f'{video_dir}camera_'
885
  video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
886
  camera_files = []
887
- for repo_file in hub().list_repo_files(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset"):
888
  if not repo_file.startswith(prefix):
889
  continue
890
  filename = repo_file[len(video_dir):]
 
30
  from fastapi.responses import FileResponse, JSONResponse
31
  from huggingface_hub import HfApi, hf_hub_download, snapshot_download
32
  from huggingface_hub.errors import RemoteEntryNotFoundError
33
+ from storage_listing import session_files, session_folder_exists
34
 
35
  load_dotenv(Path(__file__).with_name(".env"))
36
  jobs = {}
 
863
  raise HTTPException(409, "Session uses a different storage layout")
864
 
865
  if storage_path == expected_path:
866
+ if session_folder_exists(hub(), os.environ["STORAGE_REPO_ID"], storage_path):
 
 
867
  hub().delete_folder(
868
  repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
869
  path_in_repo=storage_path, commit_message=f"Delete session {session_id}",
 
883
  prefix = f'{video_dir}camera_'
884
  video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
885
  camera_files = []
886
+ for repo_file in session_files(hub(), os.environ["STORAGE_REPO_ID"], video_dir.rstrip("/")):
887
  if not repo_file.startswith(prefix):
888
  continue
889
  filename = repo_file[len(video_dir):]
package.json CHANGED
@@ -1,30 +1,31 @@
1
- {
2
- "name": "sporalize-backend",
3
- "private": true,
4
- "version": "1.0.0",
5
- "type": "module",
6
- "scripts": {
7
- "dev": "tsx watch src/server.ts",
8
- "build": "tsc -p tsconfig.json",
9
- "start": "python start.py"
10
- },
11
- "dependencies": {
12
- "@supabase/supabase-js": "^2.57.0",
13
- "bcryptjs": "^3.0.2",
14
- "cookie-parser": "^1.4.7",
15
- "cors": "^2.8.5",
16
- "dotenv": "^16.6.1",
17
- "express": "^5.1.0",
18
- "express-rate-limit": "^8.1.0",
19
- "helmet": "^8.1.0",
20
- "zod": "^4.1.5"
21
- },
22
- "devDependencies": {
23
- "@types/cookie-parser": "^1.4.9",
24
- "@types/cors": "^2.8.19",
25
- "@types/express": "^5.0.3",
26
- "@types/node": "^22.16.5",
27
- "tsx": "^4.20.5",
28
- "typescript": "^5.8.3"
29
- }
30
- }
 
 
1
+ {
2
+ "name": "sporalize-backend",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "tsx --test tests/*.test.ts",
8
+ "dev": "tsx watch src/server.ts",
9
+ "build": "tsc -p tsconfig.json",
10
+ "start": "python start.py"
11
+ },
12
+ "dependencies": {
13
+ "@supabase/supabase-js": "^2.57.0",
14
+ "bcryptjs": "^3.0.2",
15
+ "cookie-parser": "^1.4.7",
16
+ "cors": "^2.8.5",
17
+ "dotenv": "^16.6.1",
18
+ "express": "^5.1.0",
19
+ "express-rate-limit": "^8.1.0",
20
+ "helmet": "^8.1.0",
21
+ "zod": "^4.1.5"
22
+ },
23
+ "devDependencies": {
24
+ "@types/cookie-parser": "^1.4.9",
25
+ "@types/cors": "^2.8.19",
26
+ "@types/express": "^5.0.3",
27
+ "@types/node": "^22.16.5",
28
+ "tsx": "^4.20.5",
29
+ "typescript": "^5.8.3"
30
+ }
31
+ }
src/auth.ts CHANGED
@@ -3,6 +3,12 @@ import type { Request, Response } from "express";
3
  import type { Session, User } from "@supabase/supabase-js";
4
  import { env, isProduction } from "./config.js";
5
  import { supabaseAuth } from "./supabase.js";
 
 
 
 
 
 
6
 
7
  const accessCookie = "sp_access";
8
  const refreshCookie = "sp_refresh";
@@ -59,7 +65,7 @@ export async function authenticatedUser(req: Request, res: Response): Promise<{
59
  if (!accessToken && !refreshToken) return null;
60
 
61
  if (accessToken) {
62
- const { data, error } = await supabaseAuth.auth.getUser(accessToken);
63
  if (data.user && !error) return { user: data.user };
64
  }
65
 
@@ -67,7 +73,7 @@ export async function authenticatedUser(req: Request, res: Response): Promise<{
67
  clearSessionCookies(res);
68
  return null;
69
  }
70
- const { data, error } = await supabaseAuth.auth.refreshSession({ refresh_token: refreshToken });
71
  if (error || !data.user || !data.session) {
72
  clearSessionCookies(res);
73
  return null;
 
3
  import type { Session, User } from "@supabase/supabase-js";
4
  import { env, isProduction } from "./config.js";
5
  import { supabaseAuth } from "./supabase.js";
6
+ import { singleFlight } from "./single-flight.js";
7
+
8
+ // Several page, image and polling requests arrive together. Validate once for
9
+ // that burst, without extending a revoked token's lifetime through a TTL cache.
10
+ const verifyAccess = singleFlight((token: string) => supabaseAuth.auth.getUser(token));
11
+ const refreshAccess = singleFlight((token: string) => supabaseAuth.auth.refreshSession({ refresh_token: token }));
12
 
13
  const accessCookie = "sp_access";
14
  const refreshCookie = "sp_refresh";
 
65
  if (!accessToken && !refreshToken) return null;
66
 
67
  if (accessToken) {
68
+ const { data, error } = await verifyAccess(accessToken);
69
  if (data.user && !error) return { user: data.user };
70
  }
71
 
 
73
  clearSessionCookies(res);
74
  return null;
75
  }
76
+ const { data, error } = await refreshAccess(refreshToken);
77
  if (error || !data.user || !data.session) {
78
  clearSessionCookies(res);
79
  return null;
src/read-batches.ts ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Bound parallel DB reads so large rosters do not create unbounded fan-out. */
2
+ export async function readBatches<T, R>(
3
+ items: readonly T[],
4
+ load: (batch: T[]) => PromiseLike<readonly R[]>,
5
+ batchSize = 100,
6
+ concurrency = 4,
7
+ ): Promise<R[]> {
8
+ if (!Number.isInteger(batchSize) || batchSize < 1 ||
9
+ !Number.isInteger(concurrency) || concurrency < 1) throw new Error("Invalid batch configuration");
10
+ const output: R[] = [];
11
+ for (let offset = 0; offset < items.length; offset += batchSize * concurrency) {
12
+ const group: PromiseLike<readonly R[]>[] = [];
13
+ for (let cursor = offset; cursor < Math.min(items.length, offset + batchSize * concurrency); cursor += batchSize) {
14
+ group.push(load(items.slice(cursor, cursor + batchSize)));
15
+ }
16
+ for (const rows of await Promise.all(group)) output.push(...rows);
17
+ }
18
+ return output;
19
+ }
src/request-timing.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { RequestHandler } from "express";
3
+
4
+ const timings = new AsyncLocalStorage<{ upstreamMs: number; calls: number }>();
5
+
6
+ /** Measures remote response-header wait, without logging tokens or query data. */
7
+ export const timedFetch: typeof fetch = async (input, init) => {
8
+ const timing = timings.getStore();
9
+ const started = performance.now();
10
+ try {
11
+ return await fetch(input, init);
12
+ } finally {
13
+ if (timing) {
14
+ timing.calls += 1;
15
+ timing.upstreamMs += performance.now() - started;
16
+ }
17
+ }
18
+ };
19
+
20
+ export const requestTiming: RequestHandler = (req, res, next) => {
21
+ const timing = { upstreamMs: 0, calls: 0 };
22
+ const started = performance.now();
23
+ const json = res.json.bind(res);
24
+ res.json = (body) => {
25
+ res.setHeader("Server-Timing",
26
+ `app;dur=${(performance.now() - started).toFixed(1)}, supabase;dur=${timing.upstreamMs.toFixed(1)};desc="${timing.calls} upstream calls"`);
27
+ return json(body);
28
+ };
29
+ res.once("finish", () => {
30
+ const duration = performance.now() - started;
31
+ if (duration >= 1000) console.info("slow_request", {
32
+ method: req.method, route: req.route?.path ?? "(middleware)",
33
+ status: res.statusCode, durationMs: Math.round(duration),
34
+ upstreamMs: Math.round(timing.upstreamMs), upstreamCalls: timing.calls,
35
+ });
36
+ });
37
+ timings.run(timing, next);
38
+ };
src/server.ts CHANGED
@@ -16,10 +16,14 @@ import { registerProfileImageRoutes } from "./profile-images.js";
16
  import { registerAdminRoutes } from "./admin.js";
17
  import { colabWorkerRouter } from "./colab-worker.js";
18
  import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";
 
 
 
19
 
20
  const app = express();
21
  app.disable("x-powered-by");
22
  app.set("trust proxy", env.TRUST_PROXY_HOPS);
 
23
  app.use(helmet());
24
  app.use(cors({
25
  origin: env.FRONTEND_URL,
@@ -146,11 +150,11 @@ function playerSkillRatings(
146
  });
147
  }
148
 
149
- export async function accountRoleFor(userId: string) {
150
  const { data, error } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle();
151
  if (error) throw new Error(`Unable to load account permissions: ${error.message}`);
152
  return String(data?.account_role ?? "user").toLowerCase();
153
- }
154
 
155
  export async function userIsAdmin(userId: string) {
156
  return await accountRoleFor(userId) === "admin";
@@ -170,16 +174,12 @@ export async function requireAdmin(req: Request, res: Response) {
170
  }
171
 
172
  async function loadActionAnnotations(actionIds: string[]) {
173
- const annotations: Array<{ action_id: string; score: number | null }> = [];
174
- // Keep PostgREST query URLs small when a player has many analyzed actions.
175
- for (let offset = 0; offset < actionIds.length; offset += 100) {
176
- const batch = actionIds.slice(offset, offset + 100);
177
  const { data, error } = await supabaseAdmin.from("annotations")
178
  .select("action_id,score").in("action_id", batch);
179
  if (error) throw new Error(`Unable to load action scores: ${error.message}`);
180
- annotations.push(...(data ?? []));
181
- }
182
- return annotations;
183
  }
184
 
185
 
@@ -376,11 +376,6 @@ app.put("/api/players/:playerId", async (req, res, next) => {
376
  if (height < 100 || height > 230 || weight < 20 || weight > 150) {
377
  return res.status(400).json({ error: "Check the player's age, height and weight." });
378
  }
379
- let existingQuery = supabaseAdmin.from("players").select("player_id").eq("player_id", id.data);
380
- if (!isAdmin) existingQuery = existingQuery.eq("user_id", current.user.id);
381
- const { data: existing, error: readError } = await existingQuery.maybeSingle();
382
- if (readError) throw new Error(`Unable to load player: ${readError.message}`);
383
- if (!existing) return res.status(404).json({ error: "Player not found." });
384
  let updateQuery = supabaseAdmin.from("players").update({
385
  fname: input.fname,
386
  lname: input.lname?.trim() || null,
@@ -392,9 +387,17 @@ app.put("/api/players/:playerId", async (req, res, next) => {
392
  gender: input.gender,
393
  }).eq("player_id", id.data);
394
  if (!isAdmin) updateQuery = updateQuery.eq("user_id", current.user.id);
395
- const { error } = await updateQuery;
 
396
  if (error) throw new Error(`Unable to update player: ${error.message}`);
397
- return res.json({ ok: true });
 
 
 
 
 
 
 
398
  } catch (error) { next(error); }
399
  });
400
 
@@ -543,11 +546,11 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
543
  .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
544
  .order("fname", { ascending: true });
545
  if (!isAdmin) overviewQuery = overviewQuery.eq("user_id", current.user.id);
546
- const { data: overview, error: overviewError } = await overviewQuery;
547
- if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
548
  let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id");
549
  if (!isAdmin) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
550
- const { data: sessions, error: sessionsError } = await sessionsQuery;
 
 
551
  if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
552
  const { data: actions, error: actionsError } = (sessions ?? []).length
553
  ? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
@@ -558,7 +561,8 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
558
 
559
  const scoreMap = new Map(annotations.map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
560
  const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
561
- const ownActions = (actions ?? []).filter((row) => ownSessions.some((session) => session.session_id === row.session_id));
 
562
  const ownScores = ownActions.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
563
  const byAction = playerSkillRatings(ownActions, scoreMap);
564
  const sessionPlayerById = new Map((sessions ?? []).map((row) => [row.session_id, row.player_id]));
@@ -574,10 +578,6 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
574
  const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
575
  return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
576
  });
577
- let peersQuery = supabaseAdmin.from("players").select("player_id");
578
- if (!isAdmin) peersQuery = peersQuery.eq("user_id", current.user.id);
579
- const { data: peers, error: peersError } = await peersQuery;
580
- if (peersError) throw new Error(`Unable to load roster: ${peersError.message}`);
581
  const playerScores = new Map<string, number[]>();
582
  for (const action of actions ?? []) {
583
  const ownerId = sessionPlayerById.get(action.session_id);
@@ -601,7 +601,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
601
  createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
602
  lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
603
  scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
604
- squadAverage, rank: rankIndex < 0 ? null : rankIndex + 1, playerCount: peers?.length ?? 0,
605
  });
606
  } catch (error) { next(error); }
607
  });
@@ -619,14 +619,16 @@ app.get("/api/sessions-overview", async (req, res, next) => {
619
  const { data: sessions, error } = await sessionsQuery;
620
  if (error) throw new Error(`Unable to load sessions: ${error.message}`);
621
  const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
622
- const { data: players, error: playersError } = playerIds.length
623
- ? await supabaseAdmin.from("players").select("player_id,fname,lname,gender,image_path").in("player_id", playerIds)
624
- : { data: [], error: null };
625
- if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
626
  const sessionIds = (sessions ?? []).map((row) => row.session_id);
627
- const { data: actions, error: actionsError } = sessionIds.length
628
- ? await supabaseAdmin.from("actions").select("session_id,type").in("session_id", sessionIds)
629
- : { data: [], error: null };
 
 
 
 
 
 
630
  if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
631
  const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
632
  const playerImages = new Map((players ?? []).map((row) => [row.player_id, { gender: row.gender, imagePath: row.image_path }]));
@@ -1032,11 +1034,16 @@ app.get("/api/auth/profile", async (req, res, next) => {
1032
  try {
1033
  const current = await authenticatedUser(req, res);
1034
  if (!current) return res.status(401).json({ error: "Authentication required." });
1035
- await syncProfile(current.user);
1036
- const { data, error } = await supabaseAdmin.from("users")
1037
  .select("fname,lname,email,academy,role,image_path")
1038
  .eq("user_id", current.user.id)
1039
- .single();
 
 
 
 
 
 
1040
  if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
1041
  const hasPassword = userHasPassword(current.user);
1042
  return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
 
16
  import { registerAdminRoutes } from "./admin.js";
17
  import { colabWorkerRouter } from "./colab-worker.js";
18
  import { deleteStoredSession, refundAdminUsage } from "./resource-deletion.js";
19
+ import { singleFlight } from "./single-flight.js";
20
+ import { requestTiming } from "./request-timing.js";
21
+ import { readBatches } from "./read-batches.js";
22
 
23
  const app = express();
24
  app.disable("x-powered-by");
25
  app.set("trust proxy", env.TRUST_PROXY_HOPS);
26
+ app.use(requestTiming);
27
  app.use(helmet());
28
  app.use(cors({
29
  origin: env.FRONTEND_URL,
 
150
  });
151
  }
152
 
153
+ export const accountRoleFor = singleFlight(async (userId: string) => {
154
  const { data, error } = await supabaseAdmin.from("users").select("account_role").eq("user_id", userId).maybeSingle();
155
  if (error) throw new Error(`Unable to load account permissions: ${error.message}`);
156
  return String(data?.account_role ?? "user").toLowerCase();
157
+ });
158
 
159
  export async function userIsAdmin(userId: string) {
160
  return await accountRoleFor(userId) === "admin";
 
174
  }
175
 
176
  async function loadActionAnnotations(actionIds: string[]) {
177
+ return readBatches<string, { action_id: string; score: number | null }>(actionIds, async (batch) => {
 
 
 
178
  const { data, error } = await supabaseAdmin.from("annotations")
179
  .select("action_id,score").in("action_id", batch);
180
  if (error) throw new Error(`Unable to load action scores: ${error.message}`);
181
+ return data ?? [];
182
+ });
 
183
  }
184
 
185
 
 
376
  if (height < 100 || height > 230 || weight < 20 || weight > 150) {
377
  return res.status(400).json({ error: "Check the player's age, height and weight." });
378
  }
 
 
 
 
 
379
  let updateQuery = supabaseAdmin.from("players").update({
380
  fname: input.fname,
381
  lname: input.lname?.trim() || null,
 
387
  gender: input.gender,
388
  }).eq("player_id", id.data);
389
  if (!isAdmin) updateQuery = updateQuery.eq("user_id", current.user.id);
390
+ const { data, error } = await updateQuery
391
+ .select("player_id,fname,lname,bdate,height,weight,club,position,gender,image_path").maybeSingle();
392
  if (error) throw new Error(`Unable to update player: ${error.message}`);
393
+ if (!data) return res.status(404).json({ error: "Player not found." });
394
+ return res.json({ ok: true, player: {
395
+ playerId: data.player_id, fname: data.fname ?? "", lname: data.lname ?? "",
396
+ name: [data.fname, data.lname].filter(Boolean).join(" "), bdate: data.bdate,
397
+ age: ageFromBirthday(data.bdate), heightCm: String(data.height ?? ""),
398
+ weightKg: String(data.weight ?? ""), club: data.club ?? "", position: data.position ?? "",
399
+ gender: data.gender ?? null, imagePath: data.image_path ?? null,
400
+ } });
401
  } catch (error) { next(error); }
402
  });
403
 
 
546
  .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
547
  .order("fname", { ascending: true });
548
  if (!isAdmin) overviewQuery = overviewQuery.eq("user_id", current.user.id);
 
 
549
  let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id");
550
  if (!isAdmin) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
551
+ const [{ data: overview, error: overviewError }, { data: sessions, error: sessionsError }] =
552
+ await Promise.all([overviewQuery, sessionsQuery]);
553
+ if (overviewError) throw new Error(`Unable to load players: ${overviewError.message}`);
554
  if (sessionsError) throw new Error(`Unable to load sessions: ${sessionsError.message}`);
555
  const { data: actions, error: actionsError } = (sessions ?? []).length
556
  ? await supabaseAdmin.from("actions").select("action_id,session_id,type").in("session_id", sessions!.map((row) => row.session_id))
 
561
 
562
  const scoreMap = new Map(annotations.map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
563
  const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
564
+ const ownSessionIds = new Set(ownSessions.map((session) => session.session_id));
565
+ const ownActions = (actions ?? []).filter((row) => ownSessionIds.has(row.session_id));
566
  const ownScores = ownActions.map((row) => scoreMap.get(row.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
567
  const byAction = playerSkillRatings(ownActions, scoreMap);
568
  const sessionPlayerById = new Map((sessions ?? []).map((row) => [row.session_id, row.player_id]));
 
578
  const values = sessionActions.map((action) => scoreMap.get(action.action_id)).filter((score): score is number => score != null && Number.isFinite(score));
579
  return { sessionId: row.session_id, createdAt: Date.parse(row.created_at) || 0, score: meanPlayerScore(values), actionCount: sessionActions.length, scoredCount: values.length };
580
  });
 
 
 
 
581
  const playerScores = new Map<string, number[]>();
582
  for (const action of actions ?? []) {
583
  const ownerId = sessionPlayerById.get(action.session_id);
 
601
  createdAt: "", updatedAt: "", hasProfile: true, sessionCount: ownSessions.length, actionCount: ownActions.length,
602
  lastSessionAt: sessionsForPlayer[0]?.createdAt ?? 0,
603
  scores: { playerId: player.player_id, rubricVersion: "database", totalActions: ownActions.length, scoredActions: ownScores.length, byAction, byQuality: [], overall: meanPlayerScore(ownScores), sessions: sessionsForPlayer },
604
+ squadAverage, rank: rankIndex < 0 ? null : rankIndex + 1, playerCount: overview?.length ?? 0,
605
  });
606
  } catch (error) { next(error); }
607
  });
 
619
  const { data: sessions, error } = await sessionsQuery;
620
  if (error) throw new Error(`Unable to load sessions: ${error.message}`);
621
  const playerIds = [...new Set((sessions ?? []).flatMap((row) => row.player_id ? [row.player_id] : []))];
 
 
 
 
622
  const sessionIds = (sessions ?? []).map((row) => row.session_id);
623
+ const [{ data: players, error: playersError }, { data: actions, error: actionsError }] = await Promise.all([
624
+ playerIds.length
625
+ ? supabaseAdmin.from("players").select("player_id,fname,lname,gender,image_path").in("player_id", playerIds)
626
+ : Promise.resolve({ data: [], error: null }),
627
+ sessionIds.length
628
+ ? supabaseAdmin.from("actions").select("session_id,type").in("session_id", sessionIds)
629
+ : Promise.resolve({ data: [], error: null }),
630
+ ]);
631
+ if (playersError) throw new Error(`Unable to load session players: ${playersError.message}`);
632
  if (actionsError) throw new Error(`Unable to load session actions: ${actionsError.message}`);
633
  const playerNames = new Map((players ?? []).map((row) => [row.player_id, [row.fname, row.lname].filter(Boolean).join(" ")]));
634
  const playerImages = new Map((players ?? []).map((row) => [row.player_id, { gender: row.gender, imagePath: row.image_path }]));
 
1034
  try {
1035
  const current = await authenticatedUser(req, res);
1036
  if (!current) return res.status(401).json({ error: "Authentication required." });
1037
+ const readProfile = () => supabaseAdmin.from("users")
 
1038
  .select("fname,lname,email,academy,role,image_path")
1039
  .eq("user_id", current.user.id)
1040
+ .maybeSingle();
1041
+ let { data, error } = await readProfile();
1042
+ // Repair an absent profile only; normal reads must not write to the DB.
1043
+ if (!error && !data) {
1044
+ await syncProfile(current.user);
1045
+ ({ data, error } = await readProfile());
1046
+ }
1047
  if (error || !data) throw new Error(`Unable to load user profile: ${error?.message ?? "Profile not found"}`);
1048
  const hasPassword = userHasPassword(current.user);
1049
  return res.json({ profile: { ...data, imagePath: data.image_path, emailVerified: Boolean(current.user.email_confirmed_at), hasPassword } });
src/single-flight.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Share only concurrent work. Completed values and failures are never cached. */
2
+ export function singleFlight<Key, Value>(load: (key: Key) => PromiseLike<Value>) {
3
+ const pending = new Map<Key, Promise<Value>>();
4
+ return (key: Key): Promise<Value> => {
5
+ const existing = pending.get(key);
6
+ if (existing) return existing;
7
+ const request = Promise.resolve().then(() => load(key)).finally(() => {
8
+ if (pending.get(key) === request) pending.delete(key);
9
+ });
10
+ pending.set(key, request);
11
+ return request;
12
+ };
13
+ }
src/supabase.ts CHANGED
@@ -3,6 +3,7 @@ import bcrypt from "bcryptjs";
3
  import { randomBytes } from "node:crypto";
4
  import type { Request, Response } from "express";
5
  import { env, isProduction } from "./config.js";
 
6
 
7
  // Bind Google's PKCE verifier to the initiating browser, not a shared client's
8
  // in-memory store (which concurrent logins can overwrite or a restart can lose).
@@ -10,6 +11,7 @@ export function oauthClient(req: Request, res: Response) {
10
  const cookieName = "sp_oauth_verifier";
11
  const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
12
  return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
 
13
  auth: {
14
  autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
15
  storage: {
@@ -26,12 +28,14 @@ export function oauthClient(req: Request, res: Response) {
26
  }
27
 
28
  export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
 
29
  // OAuth returns a one-time code for the backend callback to exchange, rather
30
  // than placing session tokens in the browser URL fragment.
31
  auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
32
  });
33
 
34
  export const supabaseAdmin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
 
35
  auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
36
  });
37
 
 
3
  import { randomBytes } from "node:crypto";
4
  import type { Request, Response } from "express";
5
  import { env, isProduction } from "./config.js";
6
+ import { timedFetch } from "./request-timing.js";
7
 
8
  // Bind Google's PKCE verifier to the initiating browser, not a shared client's
9
  // in-memory store (which concurrent logins can overwrite or a restart can lose).
 
11
  const cookieName = "sp_oauth_verifier";
12
  const options = { httpOnly: true, secure: isProduction, sameSite: "lax" as const, path: "/api/auth" };
13
  return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
14
+ global: { fetch: timedFetch },
15
  auth: {
16
  autoRefreshToken: false, persistSession: true, detectSessionInUrl: false, flowType: "pkce",
17
  storage: {
 
28
  }
29
 
30
  export const supabaseAuth = createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
31
+ global: { fetch: timedFetch },
32
  // OAuth returns a one-time code for the backend callback to exchange, rather
33
  // than placing session tokens in the browser URL fragment.
34
  auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false, flowType: "pkce" },
35
  });
36
 
37
  export const supabaseAdmin = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
38
+ global: { fetch: timedFetch },
39
  auth: { autoRefreshToken: false, persistSession: false, detectSessionInUrl: false },
40
  });
41
 
storage_listing.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keep metadata requests scoped to the session being viewed."""
2
+ from huggingface_hub import RepoFile
3
+ from huggingface_hub.errors import RemoteEntryNotFoundError
4
+
5
+
6
+ def session_files(api, repo_id, folder):
7
+ try:
8
+ return [
9
+ entry.path for entry in api.list_repo_tree(
10
+ repo_id=repo_id, repo_type="dataset", path_in_repo=folder,
11
+ recursive=False, expand=False,
12
+ ) if isinstance(entry, RepoFile)
13
+ ]
14
+ except RemoteEntryNotFoundError:
15
+ # Old sessions can have results but no source video folder.
16
+ return []
17
+
18
+
19
+ def session_folder_exists(api, repo_id, folder):
20
+ return bool(api.get_paths_info(repo_id=repo_id, repo_type="dataset", paths=[folder]))
tests/read-batches.test.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { readBatches } from "../src/read-batches.js";
4
+
5
+ test("caps concurrent reads, keeps result order and includes the final partial batch", async () => {
6
+ let active = 0;
7
+ let peak = 0;
8
+ const input = Array.from({ length: 23 }, (_, index) => index);
9
+ const output = await readBatches(input, async (batch) => {
10
+ peak = Math.max(peak, ++active);
11
+ await new Promise((resolve) => setTimeout(resolve, batch[0] % 2 ? 1 : 5));
12
+ active--;
13
+ return batch;
14
+ }, 5, 3);
15
+ assert.equal(peak, 3);
16
+ assert.deepEqual(output, input);
17
+ });
18
+
19
+ test("does not query empty input or return incomplete results after failure", async () => {
20
+ assert.deepEqual(await readBatches([], () => { throw new Error("should not run"); }), []);
21
+ await assert.rejects(readBatches([1, 2], async () => { throw new Error("DB unavailable"); }), /DB unavailable/);
22
+ });
tests/single-flight.test.ts ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { singleFlight } from "../src/single-flight.js";
4
+
5
+ test("shares concurrent validation, isolates tokens, and revalidates after completion", async () => {
6
+ let calls = 0;
7
+ const run = singleFlight(async (token: string) => { calls++; return token; });
8
+ const first = run("one");
9
+ assert.equal(first, run("one"));
10
+ assert.notEqual(first, run("two"));
11
+ assert.equal(await first, "one");
12
+ assert.equal(calls, 2);
13
+ await run("one");
14
+ assert.equal(calls, 3);
15
+ });
16
+
17
+ test("does not retain failed validation", async () => {
18
+ let calls = 0;
19
+ const run = singleFlight(async () => { if (++calls === 1) throw new Error("network"); return "verified"; });
20
+ await assert.rejects(run("token"), /network/);
21
+ assert.equal(await run("token"), "verified");
22
+ });
tests/test_storage_listing.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ import httpx
3
+ from unittest.mock import Mock
4
+ from huggingface_hub import RepoFile, RepoFolder
5
+ from huggingface_hub.errors import RemoteEntryNotFoundError
6
+ from storage_listing import session_files, session_folder_exists
7
+
8
+
9
+ class StorageListingTest(unittest.TestCase):
10
+ def test_lists_only_requested_folder_and_excludes_directories(self):
11
+ api = Mock()
12
+ api.list_repo_tree.return_value = [
13
+ RepoFile(path="sessions/one/videos/camera_1.mp4", size=1, oid="a"),
14
+ RepoFolder(path="sessions/one/videos/subdir", oid="b"),
15
+ ]
16
+ self.assertEqual(session_files(api, "repo", "sessions/one/videos"),
17
+ ["sessions/one/videos/camera_1.mp4"])
18
+ api.list_repo_tree.assert_called_once_with(
19
+ repo_id="repo", repo_type="dataset", path_in_repo="sessions/one/videos",
20
+ recursive=False, expand=False)
21
+ api.list_repo_files.assert_not_called()
22
+
23
+ def test_missing_folder_is_empty_but_other_errors_propagate(self):
24
+ api = Mock()
25
+ api.list_repo_tree.side_effect = RemoteEntryNotFoundError(
26
+ "missing", response=httpx.Response(404, request=httpx.Request("GET", "https://example.com/tree")))
27
+ self.assertEqual(session_files(api, "repo", "folder"), [])
28
+ api.list_repo_tree.side_effect = RuntimeError("unavailable")
29
+ with self.assertRaises(RuntimeError):
30
+ session_files(api, "repo", "folder")
31
+
32
+ def test_deletion_checks_only_the_session_path(self):
33
+ api = Mock()
34
+ api.get_paths_info.return_value = []
35
+ self.assertFalse(session_folder_exists(api, "repo", "sessions/one"))
36
+ api.get_paths_info.assert_called_once_with(
37
+ repo_id="repo", repo_type="dataset", paths=["sessions/one"])
38
+ api.list_repo_files.assert_not_called()
39
+
40
+
41
+ if __name__ == "__main__":
42
+ unittest.main()