Shoraky commited on
Commit
57b63b8
·
1 Parent(s): 037944a

Reduce workspace query waterfalls and bound related reads

Browse files
PERFORMANCE.md CHANGED
@@ -24,3 +24,14 @@ Changes reduce repeated profile reads after mutations, avoid writes on ordinary
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.
 
 
 
 
 
 
 
 
 
 
 
 
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.
27
+
28
+
29
+ ## 2026-10-21 follow-up
30
+
31
+ Active subscription and billing reads now embed the plan in the assignment query. Login profile synchronization skips writes for an unchanged email. Roster player/session reads start concurrently, action ratings join their actions, and Sessions embeds its optional player. Roster, detail and session list reads use stable ordered 500-row pages; action reads use batches of 100 session IDs with at most four batches in flight and paginate inside each batch. Authorization filters remain on every scoped parent read. No completed auth/role decisions are cached.
32
+
33
+ For small, non-admin, nonempty datasets, cold `/auth/me` uses three upstream calls rather than four; unchanged-profile login uses four rather than six. Players overview drops from six dependent stages to four; Sessions saves one total call while preserving its existing four-stage dependency depth. Large datasets require more pages. These are code-derived counts, not measured production latency. The new `workspace-performance.test.ts` runs the actual Express API against a local fake Supabase server and checks scopes, role changes, revocation, scores, billing and failures. `read-pages.test.ts` covers row-cap boundaries and failures.
34
+
35
+ The join shapes were verified read-only against the configured database. See [Supabase joins and nesting](https://supabase.com/docs/guides/database/joins-and-nesting) and [range pagination](https://supabase.com/docs/reference/javascript/range). The normal Supabase row cap must be at least the 500-row page size. No schema/dependency changes are needed. Full client-facing lists and squad aggregation remain scaling work. The root repository's `PERFORMANCE.md` records the broader audit and frontend bundle measurements.
36
+
37
+ Admin related counts now page and batch children of the displayed records. The admin Players page no longer scans unrelated sessions. Existing directory result caps remain; replacing them with full UI pagination remains follow-up work. Integration tests cover the three admin read endpoints as well.
src/admin.ts CHANGED
@@ -1,3 +1,5 @@
 
 
1
  import type { Express, Request, Response, NextFunction } from "express";
2
  import { z } from "zod";
3
  import { env } from "./config.js";
@@ -56,6 +58,18 @@ async function removeImages(paths: Array<string | null | undefined>) {
56
  if (error) throw new Error(`Unable to remove profile images: ${error.message}`);
57
  }
58
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  export function registerAdminRoutes(app: Express) {
60
  app.get("/api/admin/users", asyncRoute(async (req, res) => {
61
  if (!await admin(req, res)) return;
@@ -67,12 +81,10 @@ export function registerAdminRoutes(app: Express) {
67
  const ids = (data ?? []).map((row) => row.user_id);
68
  const [subs, counts, playerCounts] = ids.length ? await Promise.all([
69
  supabaseAdmin.from("users_subscriptions").select("user_id,subscription_id,startdate,enddate,num_players,num_sessions").in("user_id", ids),
70
- supabaseAdmin.from("sessions").select("user_id").in("user_id", ids),
71
- supabaseAdmin.from("players").select("user_id").in("user_id", ids),
72
  ]) : [{ data: [], error: null }, { data: [], error: null }, { data: [], error: null }];
73
  if (subs.error) throw new Error(`Unable to load user plans: ${subs.error.message}`);
74
- if (counts.error) throw new Error(`Unable to load user session counts: ${counts.error.message}`);
75
- if (playerCounts.error) throw new Error(`Unable to load user player counts: ${playerCounts.error.message}`);
76
  const planIds = [...new Set((subs.data ?? []).flatMap((row) => row.subscription_id ? [row.subscription_id] : []))];
77
  const { data: plans, error: plansError } = planIds.length
78
  ? await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,session_limit").in("subscription_id", planIds)
@@ -175,16 +187,9 @@ export function registerAdminRoutes(app: Express) {
175
  if (error) throw new Error(`Unable to load players: ${error.message}`);
176
  if (userError) throw new Error(`Unable to load owners: ${userError.message}`);
177
  const sessionCounts = new Map<string, number>();
178
- let sessionOffset = 0;
179
- while (true) {
180
- const { data: linkedSessions, error: linkedSessionsError } = await supabaseAdmin.from("sessions")
181
- .select("player_id").not("player_id", "is", null).range(sessionOffset, sessionOffset + 999);
182
- if (linkedSessionsError) throw new Error(`Unable to count player sessions: ${linkedSessionsError.message}`);
183
- for (const session of linkedSessions ?? []) {
184
- if (session.player_id) sessionCounts.set(session.player_id, (sessionCounts.get(session.player_id) ?? 0) + 1);
185
- }
186
- if ((linkedSessions ?? []).length < 1000) break;
187
- sessionOffset += 1000;
188
  }
189
  const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
190
  return res.json({ players: (players ?? []).map((player) => {
@@ -265,10 +270,7 @@ export function registerAdminRoutes(app: Express) {
265
  const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
266
  const roster = new Map((players ?? []).map((player) => [player.player_id, player]));
267
  const ids = (sessions ?? []).map((session) => session.session_id);
268
- const { data: actionRows, error: actionsError } = ids.length
269
- ? await supabaseAdmin.from("actions").select("session_id").in("session_id", ids)
270
- : { data: [], error: null };
271
- if (actionsError) throw new Error(`Unable to load session action counts: ${actionsError.message}`);
272
  const actionCounts = new Map<string, number>();
273
  for (const action of actionRows ?? []) if (action.session_id) actionCounts.set(action.session_id, (actionCounts.get(action.session_id) ?? 0) + 1);
274
  return res.json({ sessions: (sessions ?? []).map((session) => {
 
1
+ import { readBatches } from "./read-batches.js";
2
+ import { readPages } from "./read-pages.js";
3
  import type { Express, Request, Response, NextFunction } from "express";
4
  import { z } from "zod";
5
  import { env } from "./config.js";
 
58
  if (error) throw new Error(`Unable to remove profile images: ${error.message}`);
59
  }
60
 
61
+ // Count only children of the displayed records, without oversized filters or
62
+ // silently losing rows to Supabase's response cap.
63
+ async function loadRelatedCounts(table: "players" | "sessions" | "actions", key: "user_id" | "player_id" | "session_id", ids: string[]) {
64
+ const primaryKey = table === "players" ? "player_id" : table === "sessions" ? "session_id" : "action_id";
65
+ return readBatches<string, Record<string, string | null>>(ids, (batch) => readPages(async (from, to) => {
66
+ const { data, error } = await supabaseAdmin.from(table).select(`${primaryKey},${key}`)
67
+ .in(key, batch).order(primaryKey).range(from, to);
68
+ if (error) throw new Error(`Unable to load ${table} counts: ${error.message}`);
69
+ return (data ?? []) as unknown as Record<string, string | null>[];
70
+ }));
71
+ }
72
+
73
  export function registerAdminRoutes(app: Express) {
74
  app.get("/api/admin/users", asyncRoute(async (req, res) => {
75
  if (!await admin(req, res)) return;
 
81
  const ids = (data ?? []).map((row) => row.user_id);
82
  const [subs, counts, playerCounts] = ids.length ? await Promise.all([
83
  supabaseAdmin.from("users_subscriptions").select("user_id,subscription_id,startdate,enddate,num_players,num_sessions").in("user_id", ids),
84
+ loadRelatedCounts("sessions", "user_id", ids).then((data) => ({ data, error: null })),
85
+ loadRelatedCounts("players", "user_id", ids).then((data) => ({ data, error: null })),
86
  ]) : [{ data: [], error: null }, { data: [], error: null }, { data: [], error: null }];
87
  if (subs.error) throw new Error(`Unable to load user plans: ${subs.error.message}`);
 
 
88
  const planIds = [...new Set((subs.data ?? []).flatMap((row) => row.subscription_id ? [row.subscription_id] : []))];
89
  const { data: plans, error: plansError } = planIds.length
90
  ? await supabaseAdmin.from("subscriptions").select("subscription_id,plan,price,player_limit,session_limit").in("subscription_id", planIds)
 
187
  if (error) throw new Error(`Unable to load players: ${error.message}`);
188
  if (userError) throw new Error(`Unable to load owners: ${userError.message}`);
189
  const sessionCounts = new Map<string, number>();
190
+ const linkedSessions = await loadRelatedCounts("sessions", "player_id", (players ?? []).map((player) => player.player_id));
191
+ for (const session of linkedSessions) {
192
+ if (session.player_id) sessionCounts.set(session.player_id, (sessionCounts.get(session.player_id) ?? 0) + 1);
 
 
 
 
 
 
 
193
  }
194
  const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
195
  return res.json({ players: (players ?? []).map((player) => {
 
270
  const owners = new Map((users ?? []).map((user) => [user.user_id, user]));
271
  const roster = new Map((players ?? []).map((player) => [player.player_id, player]));
272
  const ids = (sessions ?? []).map((session) => session.session_id);
273
+ const actionRows = await loadRelatedCounts("actions", "session_id", ids);
 
 
 
274
  const actionCounts = new Map<string, number>();
275
  for (const action of actionRows ?? []) if (action.session_id) actionCounts.set(action.session_id, (actionCounts.get(action.session_id) ?? 0) + 1);
276
  return res.json({ sessions: (sessions ?? []).map((session) => {
src/read-pages.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Read ordered rows in bounded pages, including children of batched IDs.
2
+ * Callers must supply a stable order and preserve their authorization filters.
3
+ */
4
+ export async function readPages<T>(
5
+ load: (from: number, to: number) => PromiseLike<readonly T[]>,
6
+ pageSize = 500,
7
+ ): Promise<T[]> {
8
+ if (!Number.isInteger(pageSize) || pageSize < 1) throw new Error("Invalid page size");
9
+ const rows: T[] = [];
10
+ for (let from = 0; ; from += pageSize) {
11
+ const page = await load(from, from + pageSize - 1);
12
+ rows.push(...page);
13
+ if (page.length < pageSize) return rows;
14
+ }
15
+ }
src/server.ts CHANGED
@@ -19,6 +19,7 @@ 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");
@@ -173,13 +174,15 @@ export async function requireAdmin(req: Request, res: Response) {
173
  return current;
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
 
@@ -250,16 +253,19 @@ app.get("/api/plan-billing", async (req, res, next) => {
250
  const current = await authenticatedUser(req, res);
251
  if (!current) return res.status(401).json({ error: "Authentication required." });
252
  const userId = current.user.id;
253
- const isAdmin = await userIsAdmin(userId);
254
- const assignmentResult = await supabaseAdmin.from("users_subscriptions")
255
- .select("subscription_id,startdate,enddate,num_players,num_sessions").eq("user_id", userId).maybeSingle();
 
 
 
256
  if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
257
  const assignment = assignmentResult.data;
258
  let subscription = null;
259
  if (!isAdmin && assignment?.subscription_id) {
260
- const { data: plan, error } = await supabaseAdmin.from("subscriptions")
261
- .select("subscription_id,plan,price,player_limit,session_limit").eq("subscription_id", assignment.subscription_id).maybeSingle();
262
- if (error) throw new Error(`Unable to load plan: ${error.message}`);
263
  if (plan) {
264
  const today = new Date().toISOString().slice(0, 10);
265
  subscription = {
@@ -454,37 +460,44 @@ app.get("/api/players-overview", async (req, res, next) => {
454
  if (!current) return res.status(401).json({ error: "Authentication required." });
455
 
456
  const isAdmin = await userIsAdmin(current.user.id);
457
- let playersQuery = supabaseAdmin.from("players")
458
- .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
459
- .order("fname", { ascending: true });
460
- if (!isAdmin) playersQuery = playersQuery.eq("user_id", current.user.id);
461
- const { data: rows, error } = await playersQuery;
462
- if (error) throw new Error(`Unable to load players: ${error.message}`);
463
-
464
- const playerIds = (rows ?? []).map((row) => row.player_id);
465
- let sessionsQuery = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
466
- .in("player_id", playerIds).order("created_at", { ascending: false });
467
- if (!isAdmin) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
468
- const { data: sessions, error: sessionsError } = playerIds.length
469
- ? await sessionsQuery
470
- : { data: [], error: null };
471
- if (sessionsError) throw new Error(`Unable to load player sessions: ${sessionsError.message}`);
 
 
 
 
 
 
 
472
 
473
  const sessionIds = (sessions ?? []).map((row) => row.session_id);
474
- const { data: actions, error: actionsError } = sessionIds.length
475
- ? await supabaseAdmin.from("actions").select("action_id,session_id,type")
476
- .in("session_id", sessionIds)
477
- : { data: [], error: null };
478
- if (actionsError) throw new Error(`Unable to load player actions: ${actionsError.message}`);
479
-
480
- const actionIds = (actions ?? []).map((row) => row.action_id);
481
- const annotations = await loadActionAnnotations(actionIds);
482
-
483
- const scoresByAction = new Map(annotations.map((row) => [row.action_id, row.score == null ? null : Number(row.score)]));
484
  const sessionsByPlayer = new Map<string, typeof sessions>();
485
- for (const session of sessions ?? []) sessionsByPlayer.set(session.player_id, [...(sessionsByPlayer.get(session.player_id) ?? []), session]);
 
 
 
 
486
  const actionsBySession = new Map<string, typeof actions>();
487
- for (const action of actions ?? []) actionsBySession.set(action.session_id, [...(actionsBySession.get(action.session_id) ?? []), action]);
 
 
 
 
488
 
489
  const players = (rows ?? []).map((row) => {
490
  const playerSessions = sessionsByPlayer.get(row.player_id) ?? [];
@@ -535,31 +548,32 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
535
  const playerId = z.string().uuid().safeParse(req.params.playerId);
536
  if (!playerId.success) return res.status(404).json({ error: "Player not found." });
537
  const isAdmin = await userIsAdmin(current.user.id);
538
- let playerQuery = supabaseAdmin.from("players").select("player_id,user_id")
539
  .eq("player_id", playerId.data);
540
  if (!isAdmin) playerQuery = playerQuery.eq("user_id", current.user.id);
541
  const { data, error } = await playerQuery.maybeSingle();
542
  if (error) throw new Error(`Unable to load player: ${error.message}`);
543
  if (!data) return res.status(404).json({ error: "Player not found." });
544
 
545
- let overviewQuery = supabaseAdmin.from("players")
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))
557
- : { data: [], error: null };
558
- if (actionsError) throw new Error(`Unable to load actions: ${actionsError.message}`);
559
- const actionIds = (actions ?? []).map((row) => row.action_id);
560
- const annotations = await loadActionAnnotations(actionIds);
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));
@@ -571,7 +585,7 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
571
  return Boolean(ownerId) && ownerId !== playerId.data;
572
  });
573
  const squadByAction = playerSkillRatings(squadActions, scoreMap);
574
- const player = overview?.find((row) => row.player_id === playerId.data);
575
  if (!player) return res.status(404).json({ error: "Player not found." });
576
  const sessionsForPlayer = ownSessions.map((row) => {
577
  const sessionActions = ownActions.filter((action) => action.session_id === row.session_id);
@@ -582,7 +596,11 @@ app.get("/api/players-overview/:playerId", async (req, res, next) => {
582
  for (const action of actions ?? []) {
583
  const ownerId = sessionPlayerById.get(action.session_id);
584
  const score = scoreMap.get(action.action_id);
585
- if (ownerId && score != null && Number.isFinite(score)) playerScores.set(ownerId, [...(playerScores.get(ownerId) ?? []), score]);
 
 
 
 
586
  }
587
  const rankedPlayers = [...playerScores.entries()]
588
  .map(([id, values]) => ({ id, score: meanPlayerScore(values)! }))
@@ -612,26 +630,21 @@ app.get("/api/sessions-overview", async (req, res, next) => {
612
  if (!current) return res.status(401).json({ error: "Authentication required." });
613
 
614
  const isAdmin = await userIsAdmin(current.user.id);
615
- let sessionsQuery = supabaseAdmin.from("sessions")
616
- .select("session_id,player_id,user_id,name,path,created_at,status,by_admin")
617
- .order("created_at", { ascending: false });
618
- if (!isAdmin) sessionsQuery = sessionsQuery.eq("user_id", current.user.id);
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 }]));
635
  const breakdownBySession = new Map<string, Record<string, number>>();
636
  for (const action of actions ?? []) {
637
  const breakdown = breakdownBySession.get(action.session_id) ?? {};
@@ -640,10 +653,11 @@ app.get("/api/sessions-overview", async (req, res, next) => {
640
  }
641
  return res.json({ sessions: (sessions ?? []).map((row) => {
642
  const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
 
643
  return {
644
- sessionId: row.session_id, isOwner: row.user_id === current.user.id, canDelete: isAdmin || row.user_id === current.user.id, isAdmin, byAdmin: row.by_admin, playerId: row.player_id ?? "", playerName: row.player_id ? playerNames.get(row.player_id) ?? "" : "Deleted player",
645
- playerGender: row.player_id ? playerImages.get(row.player_id)?.gender ?? null : null,
646
- playerImagePath: row.player_id ? playerImages.get(row.player_id)?.imagePath ?? null : null,
647
  name: row.name, createdAt: Date.parse(row.created_at) || 0,
648
  actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
649
  cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
 
19
  import { singleFlight } from "./single-flight.js";
20
  import { requestTiming } from "./request-timing.js";
21
  import { readBatches } from "./read-batches.js";
22
+ import { readPages } from "./read-pages.js";
23
 
24
  const app = express();
25
  app.disable("x-powered-by");
 
174
  return current;
175
  }
176
 
177
+ type ScoredAction = { action_id: string; session_id: string; type: string; annotation: { score: number | null } | null };
178
+ async function loadScoredActions(sessionIds: string[]) {
179
+ return readBatches<string, ScoredAction>(sessionIds, (batch) => readPages(async (from, to) => {
180
+ const { data, error } = await supabaseAdmin.from("actions")
181
+ .select("action_id,session_id,type,annotation:annotations(score)").in("session_id", batch)
182
+ .order("action_id").range(from, to);
183
  if (error) throw new Error(`Unable to load action scores: ${error.message}`);
184
+ return (data ?? []) as unknown as ScoredAction[];
185
+ }));
186
  }
187
 
188
 
 
253
  const current = await authenticatedUser(req, res);
254
  if (!current) return res.status(401).json({ error: "Authentication required." });
255
  const userId = current.user.id;
256
+ const [isAdmin, assignmentResult] = await Promise.all([
257
+ userIsAdmin(userId),
258
+ supabaseAdmin.from("users_subscriptions")
259
+ .select("subscription_id,startdate,enddate,num_players,num_sessions,plan:subscriptions(subscription_id,plan,price,player_limit,session_limit)")
260
+ .eq("user_id", userId).maybeSingle(),
261
+ ]);
262
  if (assignmentResult.error) throw new Error(`Unable to load plan assignment: ${assignmentResult.error.message}`);
263
  const assignment = assignmentResult.data;
264
  let subscription = null;
265
  if (!isAdmin && assignment?.subscription_id) {
266
+ const plan = assignment.plan as unknown as {
267
+ subscription_id: string; plan: string; price: number; player_limit: number | null; session_limit: number | null;
268
+ } | null;
269
  if (plan) {
270
  const today = new Date().toISOString().slice(0, 10);
271
  subscription = {
 
460
  if (!current) return res.status(401).json({ error: "Authentication required." });
461
 
462
  const isAdmin = await userIsAdmin(current.user.id);
463
+ // These reads share only the authenticated scope, so start them together.
464
+ const [rows, visibleSessions] = await Promise.all([
465
+ readPages(async (from, to) => {
466
+ let query = supabaseAdmin.from("players")
467
+ .select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
468
+ .order("fname").order("player_id").range(from, to);
469
+ if (!isAdmin) query = query.eq("user_id", current.user.id);
470
+ const { data, error } = await query;
471
+ if (error) throw new Error(`Unable to load players: ${error.message}`);
472
+ return data ?? [];
473
+ }),
474
+ readPages(async (from, to) => {
475
+ let query = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
476
+ .order("created_at", { ascending: false }).order("session_id").range(from, to);
477
+ if (!isAdmin) query = query.eq("user_id", current.user.id);
478
+ const { data, error } = await query;
479
+ if (error) throw new Error(`Unable to load player sessions: ${error.message}`);
480
+ return data ?? [];
481
+ }),
482
+ ]);
483
+ const playerIds = new Set(rows.map((row) => row.player_id));
484
+ const sessions = visibleSessions.filter((row) => playerIds.has(row.player_id));
485
 
486
  const sessionIds = (sessions ?? []).map((row) => row.session_id);
487
+ const actions = await loadScoredActions(sessionIds);
488
+ const scoresByAction = new Map(actions.map((row) => [row.action_id, row.annotation?.score == null ? null : Number(row.annotation.score)]));
 
 
 
 
 
 
 
 
489
  const sessionsByPlayer = new Map<string, typeof sessions>();
490
+ for (const session of sessions ?? []) {
491
+ const group = sessionsByPlayer.get(session.player_id) ?? [];
492
+ group.push(session);
493
+ sessionsByPlayer.set(session.player_id, group);
494
+ }
495
  const actionsBySession = new Map<string, typeof actions>();
496
+ for (const action of actions) {
497
+ const group = actionsBySession.get(action.session_id) ?? [];
498
+ group.push(action);
499
+ actionsBySession.set(action.session_id, group);
500
+ }
501
 
502
  const players = (rows ?? []).map((row) => {
503
  const playerSessions = sessionsByPlayer.get(row.player_id) ?? [];
 
548
  const playerId = z.string().uuid().safeParse(req.params.playerId);
549
  if (!playerId.success) return res.status(404).json({ error: "Player not found." });
550
  const isAdmin = await userIsAdmin(current.user.id);
551
+ let playerQuery = supabaseAdmin.from("players").select("player_id,user_id,fname,lname,bdate,height,weight,position,club,gender,image_path")
552
  .eq("player_id", playerId.data);
553
  if (!isAdmin) playerQuery = playerQuery.eq("user_id", current.user.id);
554
  const { data, error } = await playerQuery.maybeSingle();
555
  if (error) throw new Error(`Unable to load player: ${error.message}`);
556
  if (!data) return res.status(404).json({ error: "Player not found." });
557
 
558
+ const [overview, sessions] = await Promise.all([
559
+ readPages(async (from, to) => {
560
+ let query = supabaseAdmin.from("players").select("player_id").order("player_id").range(from, to);
561
+ if (!isAdmin) query = query.eq("user_id", current.user.id);
562
+ const { data, error } = await query;
563
+ if (error) throw new Error(`Unable to load players: ${error.message}`);
564
+ return data ?? [];
565
+ }),
566
+ readPages(async (from, to) => {
567
+ let query = supabaseAdmin.from("sessions").select("session_id,player_id,name,created_at,user_id")
568
+ .order("created_at", { ascending: false }).order("session_id").range(from, to);
569
+ if (!isAdmin) query = query.eq("user_id", current.user.id);
570
+ const { data, error } = await query;
571
+ if (error) throw new Error(`Unable to load sessions: ${error.message}`);
572
+ return data ?? [];
573
+ }),
574
+ ]);
575
+ const actions = await loadScoredActions((sessions ?? []).map((row) => row.session_id));
576
+ const scoreMap = new Map(actions.map((row) => [row.action_id, row.annotation?.score == null ? null : Number(row.annotation.score)]));
577
  const ownSessions = (sessions ?? []).filter((row) => row.player_id === playerId.data);
578
  const ownSessionIds = new Set(ownSessions.map((session) => session.session_id));
579
  const ownActions = (actions ?? []).filter((row) => ownSessionIds.has(row.session_id));
 
585
  return Boolean(ownerId) && ownerId !== playerId.data;
586
  });
587
  const squadByAction = playerSkillRatings(squadActions, scoreMap);
588
+ const player = data;
589
  if (!player) return res.status(404).json({ error: "Player not found." });
590
  const sessionsForPlayer = ownSessions.map((row) => {
591
  const sessionActions = ownActions.filter((action) => action.session_id === row.session_id);
 
596
  for (const action of actions ?? []) {
597
  const ownerId = sessionPlayerById.get(action.session_id);
598
  const score = scoreMap.get(action.action_id);
599
+ if (ownerId && score != null && Number.isFinite(score)) {
600
+ const values = playerScores.get(ownerId) ?? [];
601
+ values.push(score);
602
+ playerScores.set(ownerId, values);
603
+ }
604
  }
605
  const rankedPlayers = [...playerScores.entries()]
606
  .map(([id, values]) => ({ id, score: meanPlayerScore(values)! }))
 
630
  if (!current) return res.status(401).json({ error: "Authentication required." });
631
 
632
  const isAdmin = await userIsAdmin(current.user.id);
633
+ const sessions = await readPages(async (from, to) => {
634
+ let query = supabaseAdmin.from("sessions")
635
+ .select("session_id,player_id,user_id,name,path,created_at,status,by_admin,player:players(fname,lname,gender,image_path)")
636
+ .order("created_at", { ascending: false }).order("session_id").range(from, to);
637
+ if (!isAdmin) query = query.eq("user_id", current.user.id);
638
+ const { data, error } = await query;
639
+ if (error) throw new Error(`Unable to load sessions: ${error.message}`);
640
+ return data ?? [];
641
+ });
642
+ const actions = await readBatches(sessions.map((row) => row.session_id), (batch) => readPages(async (from, to) => {
643
+ const { data, error } = await supabaseAdmin.from("actions").select("action_id,session_id,type")
644
+ .in("session_id", batch).order("action_id").range(from, to);
645
+ if (error) throw new Error(`Unable to load session actions: ${error.message}`);
646
+ return data ?? [];
647
+ }));
 
 
 
 
 
648
  const breakdownBySession = new Map<string, Record<string, number>>();
649
  for (const action of actions ?? []) {
650
  const breakdown = breakdownBySession.get(action.session_id) ?? {};
 
653
  }
654
  return res.json({ sessions: (sessions ?? []).map((row) => {
655
  const actionBreakdown = breakdownBySession.get(row.session_id) ?? {};
656
+ const player = row.player as unknown as { fname: string; lname: string | null; gender: string | null; image_path: string | null } | null;
657
  return {
658
+ sessionId: row.session_id, isOwner: row.user_id === current.user.id, canDelete: isAdmin || row.user_id === current.user.id, isAdmin, byAdmin: row.by_admin, playerId: row.player_id ?? "", playerName: row.player_id ? [player?.fname, player?.lname].filter(Boolean).join(" ") : "Deleted player",
659
+ playerGender: row.player_id ? player?.gender ?? null : null,
660
+ playerImagePath: row.player_id ? player?.image_path ?? null : null,
661
  name: row.name, createdAt: Date.parse(row.created_at) || 0,
662
  actionCount: Object.values(actionBreakdown).reduce((sum, count) => sum + count, 0),
663
  cameraCount: 0, actionBreakdown, path: row.path, status: row.status,
src/subscriptions.ts CHANGED
@@ -40,13 +40,16 @@ export async function assignPaidSubscription(input: AssignSubscriptionInput) {
40
  async function loadActiveSubscription(userId: string) {
41
  const today = new Date().toISOString().slice(0, 10);
42
  const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
43
- .select("subscription_id,startdate,enddate").eq("user_id", userId).maybeSingle();
 
44
  if (error) throw new Error(`Unable to check subscription: ${error.message}`);
45
  if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
46
 
47
- const { data: plan, error: planError } = await supabaseAdmin.from("subscriptions")
48
- .select("subscription_id,plan,price,player_limit,session_limit").eq("subscription_id", assignment.subscription_id).maybeSingle();
49
- if (planError) throw new Error(`Unable to load subscription plan: ${planError.message}`);
 
 
50
  return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
51
  }
52
 
 
40
  async function loadActiveSubscription(userId: string) {
41
  const today = new Date().toISOString().slice(0, 10);
42
  const { data: assignment, error } = await supabaseAdmin.from("users_subscriptions")
43
+ .select("subscription_id,startdate,enddate,plan:subscriptions(subscription_id,plan,price,player_limit,session_limit)")
44
+ .eq("user_id", userId).maybeSingle();
45
  if (error) throw new Error(`Unable to check subscription: ${error.message}`);
46
  if (!assignment?.subscription_id || assignment.startdate > today || assignment.enddate < today) return null;
47
 
48
+ // The FK is many-to-one; untyped Supabase clients infer embedded rows as arrays.
49
+ const plan = assignment.plan as unknown as {
50
+ subscription_id: string; plan: string; price: number;
51
+ player_limit: number | null; session_limit: number | null;
52
+ } | null;
53
  return plan ? { ...plan, startdate: assignment.startdate, enddate: assignment.enddate } : null;
54
  }
55
 
src/supabase.ts CHANGED
@@ -53,9 +53,10 @@ async function randomPasswordHash() {
53
 
54
  export async function syncProfile(user: User, profile?: { fname?: string; lname?: string; academy?: string | null; role?: string | null; passwordHash?: string }) {
55
  const metadata = user.user_metadata ?? {};
56
- const { data: existing, error: readError } = await supabaseAdmin.from("users").select("user_id").eq("user_id", user.id).maybeSingle();
57
  if (readError) throw new Error(`Unable to read user profile: ${readError.message}`);
58
  if (existing) {
 
59
  // The profile table is the canonical source for editable profile fields.
60
  // Auth callbacks should only keep the email in sync, never reset user edits.
61
  const { error } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id);
 
53
 
54
  export async function syncProfile(user: User, profile?: { fname?: string; lname?: string; academy?: string | null; role?: string | null; passwordHash?: string }) {
55
  const metadata = user.user_metadata ?? {};
56
+ const { data: existing, error: readError } = await supabaseAdmin.from("users").select("user_id,email").eq("user_id", user.id).maybeSingle();
57
  if (readError) throw new Error(`Unable to read user profile: ${readError.message}`);
58
  if (existing) {
59
+ if (existing.email === user.email) return;
60
  // The profile table is the canonical source for editable profile fields.
61
  // Auth callbacks should only keep the email in sync, never reset user edits.
62
  const { error } = await supabaseAdmin.from("users").update({ email: user.email }).eq("user_id", user.id);
tests/read-pages.test.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { readPages } from "../src/read-pages.js";
4
+
5
+ test("reads beyond the upstream row cap with inclusive, ordered ranges", async () => {
6
+ const input = Array.from({ length: 1203 }, (_, index) => index);
7
+ const ranges: number[][] = [];
8
+ const rows = await readPages(async (from, to) => {
9
+ ranges.push([from, to]);
10
+ return input.slice(from, to + 1);
11
+ });
12
+ assert.deepEqual(rows, input);
13
+ assert.deepEqual(ranges, [[0, 499], [500, 999], [1000, 1499]]);
14
+ });
15
+
16
+ test("handles empty and exact page boundaries, and propagates failure", async () => {
17
+ assert.deepEqual(await readPages(async () => []), []);
18
+ let calls = 0;
19
+ assert.deepEqual(await readPages(async (from) => { calls++; return from ? [] : [1, 2]; }, 2), [1, 2]);
20
+ assert.equal(calls, 2);
21
+ await assert.rejects(readPages(async () => { throw new Error("offline"); }), /offline/);
22
+ await assert.rejects(readPages(async () => [], 0), /Invalid page size/);
23
+ });
tests/workspace-performance.test.ts ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { once } from "node:events";
4
+ import { createServer } from "node:http";
5
+ import type { AddressInfo } from "node:net";
6
+ import { test } from "node:test";
7
+
8
+ test("optimized workspace reads preserve authorization, scores and fresh permission checks", { timeout: 30_000 }, async () => {
9
+ const owner = "11111111-1111-4111-8111-111111111111";
10
+ const other = "22222222-2222-4222-8222-222222222222";
11
+ const playerId = "33333333-3333-4333-8333-333333333333";
12
+ const otherPlayerId = "44444444-4444-4444-8444-444444444444";
13
+ const calls: { method: string; url: URL }[] = [];
14
+ let role = "user";
15
+ let revoked = false;
16
+ let failActions = false;
17
+ const user = { id: owner, email: "test@example.com", email_confirmed_at: new Date().toISOString(), user_metadata: {} };
18
+ const plan = { subscription_id: "plan", plan: "Pro", price: 10, player_limit: 5, session_limit: 10 };
19
+ const player = { player_id: playerId, user_id: owner, fname: "Own", lname: "Player", bdate: "2000-01-01", gender: "male", image_path: null };
20
+ const sessions = [
21
+ { session_id: "s1", player_id: playerId, user_id: owner, created_at: "2026-01-02T00:00:00Z", status: "completed", name: "Own session", player },
22
+ { session_id: "s2", player_id: otherPlayerId, user_id: other, created_at: "2026-01-01T00:00:00Z", status: "completed", name: "Other session", player: { fname: "Other" } },
23
+ ];
24
+ const actions = [
25
+ { action_id: "a1", session_id: "s1", type: "Pass", annotation: { score: 7 } },
26
+ { action_id: "a2", session_id: "s1", type: "Pass", annotation: null },
27
+ { action_id: "a3", session_id: "s2", type: "Pass", annotation: { score: 10 } },
28
+ ];
29
+ const upstream = createServer((req, res) => {
30
+ const url = new URL(req.url!, "http://stub");
31
+ calls.push({ method: req.method!, url });
32
+ res.setHeader("Content-Type", "application/json");
33
+ if (url.pathname === "/auth/v1/user") {
34
+ res.statusCode = revoked ? 401 : 200;
35
+ return res.end(JSON.stringify(revoked ? { message: "Revoked" } : user));
36
+ }
37
+ if (url.pathname === "/auth/v1/token") {
38
+ return res.end(JSON.stringify({ user, access_token: "access", refresh_token: "refresh", expires_in: 3600, token_type: "bearer" }));
39
+ }
40
+ const table = url.pathname.split("/").at(-1);
41
+ if (table === "actions" && failActions) {
42
+ res.statusCode = 400;
43
+ return res.end(JSON.stringify({ message: "Unavailable" }));
44
+ }
45
+ let rows: Record<string, unknown>[] = table === "users" ? [{ user_id: owner, email: user.email, account_role: role }]
46
+ : table === "users_subscriptions" ? [{ user_id: owner, subscription_id: "plan", startdate: "2000-01-01", enddate: "2099-01-01", num_players: 1, num_sessions: 1, plan }]
47
+ : table === "players" ? [player, { player_id: otherPlayerId, user_id: other, fname: "Other" }]
48
+ : table === "sessions" ? sessions : table === "actions" ? actions : [];
49
+ for (const [key, filter] of url.searchParams) {
50
+ if (filter.startsWith("eq.")) rows = rows.filter((row) => String(row[key]) === filter.slice(3));
51
+ if (filter.startsWith("in.(")) {
52
+ const values = filter.slice(4, -1).split(",");
53
+ rows = rows.filter((row) => values.includes(String(row[key])));
54
+ }
55
+ }
56
+ const from = Number(url.searchParams.get("offset") ?? 0);
57
+ const limit = Number(url.searchParams.get("limit") ?? rows.length);
58
+ res.end(JSON.stringify(rows.slice(from, from + limit)));
59
+ });
60
+ upstream.listen(0, "127.0.0.1");
61
+ await once(upstream, "listening");
62
+ const portProbe = createServer();
63
+ portProbe.listen(0, "127.0.0.1");
64
+ await once(portProbe, "listening");
65
+ const port = (portProbe.address() as AddressInfo).port;
66
+ await new Promise<void>((resolve) => portProbe.close(() => resolve()));
67
+ const origin = `http://127.0.0.1:${port}`;
68
+ const child = spawn(process.execPath, ["--import", "tsx", "src/server.ts"], {
69
+ env: { ...process.env, NODE_ENV: "test", PORT: String(port), FRONTEND_URL: origin, BACKEND_URL: origin,
70
+ SUPABASE_URL: `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`,
71
+ SUPABASE_ANON_KEY: "test-anon", SUPABASE_SERVICE_ROLE_KEY: "test-service", ANALYSIS_PROXY_SECRET: "x".repeat(32) },
72
+ stdio: ["ignore", "pipe", "pipe"],
73
+ });
74
+ try {
75
+ await new Promise<void>((resolve, reject) => {
76
+ child.stdout.on("data", (data) => { if (String(data).includes("API listening")) resolve(); });
77
+ child.once("exit", (code) => reject(new Error(`API exited: ${code}`)));
78
+ child.once("error", reject);
79
+ });
80
+ const request = (path: string, init: RequestInit = {}) => fetch(`${origin}${path}`, {
81
+ ...init, signal: AbortSignal.timeout(10000), headers: { Cookie: "sp_access=access", Origin: origin, ...init.headers },
82
+ }).catch((error) => { throw new Error(`Request failed: ${path}`, { cause: error }); });
83
+ assert.equal((await fetch(`${origin}/api/players-overview`)).status, 401);
84
+ const meStart = calls.length;
85
+ const me = await (await request("/api/auth/me")).json();
86
+ assert.equal(me.access.active, true);
87
+ assert.equal(me.access.subscription.plan, "Pro");
88
+ assert.equal(calls.length - meStart, 3); // auth + role + joined assignment/plan
89
+ const rosterResponse = await request("/api/players-overview");
90
+ assert.equal(rosterResponse.status, 200);
91
+ const roster = (await rosterResponse.json()).players;
92
+ assert.equal(roster.length, 1);
93
+ assert.equal(roster[0].scores.overall, 70);
94
+ assert.equal(roster[0].actionCount, 2);
95
+ assert.equal(roster[0].scores.scoredActions, 1);
96
+ const list = (await (await request("/api/sessions-overview")).json()).sessions;
97
+ assert.equal(list.length, 1);
98
+ assert.equal(list[0].playerName, "Own Player");
99
+ assert.equal(list[0].actionBreakdown.Pass, 2);
100
+ const detail = await (await request(`/api/players-overview/${playerId}`)).json();
101
+ assert.equal(detail.scores.overall, 70);
102
+ assert.equal(detail.playerCount, 1);
103
+ assert.equal((await request(`/api/players-overview/${otherPlayerId}`)).status, 404);
104
+ const billing = await (await request("/api/plan-billing")).json();
105
+ assert.equal(billing.subscription.name, "Pro");
106
+ assert.equal(billing.usage.sessions, 1);
107
+ assert.ok(!calls.some(({ url }) => url.pathname.endsWith("/annotations")));
108
+ const login = await request("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: user.email, password: "password123" }) });
109
+ assert.equal(login.status, 200);
110
+ assert.ok(!calls.some(({ method }) => method === "PATCH"));
111
+ role = "admin";
112
+ const adminPlayers = await (await request("/api/admin/players")).json();
113
+ assert.equal(adminPlayers.players.find((row: { player_id: string }) => row.player_id === playerId).sessionCount, 1);
114
+ const adminSessions = await (await request("/api/admin/sessions")).json();
115
+ assert.equal(adminSessions.sessions.find((row: { session_id: string }) => row.session_id === "s1").actionCount, 2);
116
+ const adminUsers = await (await request("/api/admin/users")).json();
117
+ assert.equal(adminUsers.users[0].dataCounts.sessions, 1);
118
+ const adminRows = (await (await request("/api/players-overview")).json()).players;
119
+ assert.equal(adminRows.length, 2);
120
+ role = "user";
121
+ assert.equal((await (await request("/api/players-overview")).json()).players.length, 1);
122
+ failActions = true;
123
+ assert.equal((await request("/api/sessions-overview")).status, 500);
124
+ revoked = true;
125
+ assert.equal((await request("/api/sessions-overview")).status, 401);
126
+ } finally {
127
+ if (child.exitCode === null && child.signalCode === null) {
128
+ const exited = once(child, "exit");
129
+ child.kill();
130
+ await exited;
131
+ }
132
+ upstream.closeAllConnections();
133
+ await new Promise<void>((resolve) => upstream.close(() => resolve()));
134
+ }
135
+ });