🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add cursor-based pagination to all list endpoints

Implement cursor-based pagination with base64url-encoded cursors for:
- GET /api/transcriptions (user's transcriptions)
- GET /api/admin/transcriptions (all transcriptions)
- GET /api/admin/users (all users with stats)
- GET /api/classes (user's classes)

Response format: { data: [...], pagination: { limit, hasMore, nextCursor } }
Query params: ?limit=50&cursor=<base64url-string>

Cursors are opaque, URL-safe, short strings (20-40 chars) that prevent
page drift and support efficient pagination at any scale.

Implementation:
- Created src/lib/cursor.ts for encoding/decoding cursors
- Updated getAllTranscriptions, getAllUsersWithStats, getClassesForUser
- Default limit: 50, max: 100
- Comprehensive test coverage (23 tests)

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>

+1029 -109
+148 -24
src/index.ts
··· 2090 2090 GET: async (req) => { 2091 2091 try { 2092 2092 const user = requireSubscription(req); 2093 + const url = new URL(req.url); 2093 2094 2094 - const transcriptions = db 2095 - .query< 2096 - { 2097 - id: string; 2098 - filename: string; 2099 - original_filename: string; 2100 - class_id: string | null; 2101 - status: string; 2102 - progress: number; 2103 - created_at: number; 2104 - }, 2105 - [number] 2106 - >( 2107 - "SELECT id, filename, original_filename, class_id, status, progress, created_at FROM transcriptions WHERE user_id = ? ORDER BY created_at DESC", 2108 - ) 2109 - .all(user.id); 2095 + // Parse pagination params 2096 + const limit = Math.min( 2097 + Number.parseInt(url.searchParams.get("limit") || "50", 10), 2098 + 100, 2099 + ); 2100 + const cursorParam = url.searchParams.get("cursor"); 2101 + 2102 + let transcriptions: Array<{ 2103 + id: string; 2104 + filename: string; 2105 + original_filename: string; 2106 + class_id: string | null; 2107 + status: string; 2108 + progress: number; 2109 + created_at: number; 2110 + }>; 2111 + 2112 + if (cursorParam) { 2113 + // Decode cursor 2114 + const { decodeCursor } = await import("./lib/cursor"); 2115 + const parts = decodeCursor(cursorParam); 2116 + 2117 + if (parts.length !== 2) { 2118 + return Response.json( 2119 + { error: "Invalid cursor format" }, 2120 + { status: 400 }, 2121 + ); 2122 + } 2123 + 2124 + const cursorTime = Number.parseInt(parts[0] || "", 10); 2125 + const id = parts[1] || ""; 2126 + 2127 + if (Number.isNaN(cursorTime) || !id) { 2128 + return Response.json( 2129 + { error: "Invalid cursor format" }, 2130 + { status: 400 }, 2131 + ); 2132 + } 2133 + 2134 + transcriptions = db 2135 + .query< 2136 + { 2137 + id: string; 2138 + filename: string; 2139 + original_filename: string; 2140 + class_id: string | null; 2141 + status: string; 2142 + progress: number; 2143 + created_at: number; 2144 + }, 2145 + [number, number, string, number] 2146 + >( 2147 + `SELECT id, filename, original_filename, class_id, status, progress, created_at 2148 + FROM transcriptions 2149 + WHERE user_id = ? AND (created_at < ? OR (created_at = ? AND id < ?)) 2150 + ORDER BY created_at DESC, id DESC 2151 + LIMIT ?`, 2152 + ) 2153 + .all(user.id, cursorTime, cursorTime, id, limit + 1); 2154 + } else { 2155 + transcriptions = db 2156 + .query< 2157 + { 2158 + id: string; 2159 + filename: string; 2160 + original_filename: string; 2161 + class_id: string | null; 2162 + status: string; 2163 + progress: number; 2164 + created_at: number; 2165 + }, 2166 + [number, number] 2167 + >( 2168 + `SELECT id, filename, original_filename, class_id, status, progress, created_at 2169 + FROM transcriptions 2170 + WHERE user_id = ? 2171 + ORDER BY created_at DESC, id DESC 2172 + LIMIT ?`, 2173 + ) 2174 + .all(user.id, limit + 1); 2175 + } 2176 + 2177 + // Check if there are more results 2178 + const hasMore = transcriptions.length > limit; 2179 + if (hasMore) { 2180 + transcriptions.pop(); // Remove extra item 2181 + } 2182 + 2183 + // Build next cursor 2184 + let nextCursor: string | null = null; 2185 + if (hasMore && transcriptions.length > 0) { 2186 + const { encodeCursor } = await import("./lib/cursor"); 2187 + const last = transcriptions[transcriptions.length - 1]; 2188 + if (last) { 2189 + nextCursor = encodeCursor([ 2190 + last.created_at.toString(), 2191 + last.id, 2192 + ]); 2193 + } 2194 + } 2110 2195 2111 2196 // Load transcripts from files for completed jobs 2112 2197 const jobs = await Promise.all( ··· 2122 2207 }), 2123 2208 ); 2124 2209 2125 - return Response.json({ jobs }); 2210 + return Response.json({ 2211 + jobs, 2212 + pagination: { 2213 + limit, 2214 + hasMore, 2215 + nextCursor, 2216 + }, 2217 + }); 2126 2218 } catch (error) { 2127 2219 return handleError(error); 2128 2220 } ··· 2238 2330 GET: async (req) => { 2239 2331 try { 2240 2332 requireAdmin(req); 2241 - const transcriptions = getAllTranscriptions(); 2242 - return Response.json(transcriptions); 2333 + const url = new URL(req.url); 2334 + 2335 + const limit = Math.min( 2336 + Number.parseInt(url.searchParams.get("limit") || "50", 10), 2337 + 100, 2338 + ); 2339 + const cursor = url.searchParams.get("cursor") || undefined; 2340 + 2341 + const result = getAllTranscriptions(limit, cursor); 2342 + return Response.json(result); 2243 2343 } catch (error) { 2244 2344 return handleError(error); 2245 2345 } ··· 2249 2349 GET: async (req) => { 2250 2350 try { 2251 2351 requireAdmin(req); 2252 - const users = getAllUsersWithStats(); 2253 - return Response.json(users); 2352 + const url = new URL(req.url); 2353 + 2354 + const limit = Math.min( 2355 + Number.parseInt(url.searchParams.get("limit") || "50", 10), 2356 + 100, 2357 + ); 2358 + const cursor = url.searchParams.get("cursor") || undefined; 2359 + 2360 + const result = getAllUsersWithStats(limit, cursor); 2361 + return Response.json(result); 2254 2362 } catch (error) { 2255 2363 return handleError(error); 2256 2364 } ··· 2799 2907 GET: async (req) => { 2800 2908 try { 2801 2909 const user = requireAuth(req); 2802 - const classes = getClassesForUser(user.id, user.role === "admin"); 2910 + const url = new URL(req.url); 2911 + 2912 + const limit = Math.min( 2913 + Number.parseInt(url.searchParams.get("limit") || "50", 10), 2914 + 100, 2915 + ); 2916 + const cursor = url.searchParams.get("cursor") || undefined; 2917 + 2918 + const result = getClassesForUser( 2919 + user.id, 2920 + user.role === "admin", 2921 + limit, 2922 + cursor, 2923 + ); 2803 2924 2804 2925 // Group by semester/year 2805 2926 const grouped: Record< ··· 2815 2936 }> 2816 2937 > = {}; 2817 2938 2818 - for (const cls of classes) { 2939 + for (const cls of result.data) { 2819 2940 const key = `${cls.semester} ${cls.year}`; 2820 2941 if (!grouped[key]) { 2821 2942 grouped[key] = []; ··· 2831 2952 }); 2832 2953 } 2833 2954 2834 - return Response.json({ classes: grouped }); 2955 + return Response.json({ 2956 + classes: grouped, 2957 + pagination: result.pagination, 2958 + }); 2835 2959 } catch (error) { 2836 2960 return handleError(error); 2837 2961 }
+204 -59
src/lib/auth.ts
··· 482 482 .all(); 483 483 } 484 484 485 - export function getAllTranscriptions(): Array<{ 486 - id: string; 487 - user_id: number; 488 - user_email: string; 489 - user_name: string | null; 490 - original_filename: string; 491 - status: string; 492 - created_at: number; 493 - error_message: string | null; 494 - }> { 495 - return db 496 - .query< 497 - { 498 - id: string; 499 - user_id: number; 500 - user_email: string; 501 - user_name: string | null; 502 - original_filename: string; 503 - status: string; 504 - created_at: number; 505 - error_message: string | null; 506 - }, 507 - [] 508 - >( 509 - `SELECT 510 - t.id, 511 - t.user_id, 512 - u.email as user_email, 513 - u.name as user_name, 514 - t.original_filename, 515 - t.status, 516 - t.created_at, 517 - t.error_message 518 - FROM transcriptions t 519 - LEFT JOIN users u ON t.user_id = u.id 520 - ORDER BY t.created_at DESC`, 521 - ) 522 - .all(); 485 + export function getAllTranscriptions( 486 + limit = 50, 487 + cursor?: string, 488 + ): { 489 + data: Array<{ 490 + id: string; 491 + user_id: number; 492 + user_email: string; 493 + user_name: string | null; 494 + original_filename: string; 495 + status: string; 496 + created_at: number; 497 + error_message: string | null; 498 + }>; 499 + pagination: { 500 + limit: number; 501 + hasMore: boolean; 502 + nextCursor: string | null; 503 + }; 504 + } { 505 + type TranscriptionRow = { 506 + id: string; 507 + user_id: number; 508 + user_email: string; 509 + user_name: string | null; 510 + original_filename: string; 511 + status: string; 512 + created_at: number; 513 + error_message: string | null; 514 + }; 515 + 516 + let transcriptions: TranscriptionRow[]; 517 + 518 + if (cursor) { 519 + const { decodeCursor } = require("./cursor"); 520 + const parts = decodeCursor(cursor); 521 + 522 + if (parts.length !== 2) { 523 + throw new Error("Invalid cursor format"); 524 + } 525 + 526 + const cursorTime = Number.parseInt(parts[0] || "", 10); 527 + const id = parts[1] || ""; 528 + 529 + if (Number.isNaN(cursorTime) || !id) { 530 + throw new Error("Invalid cursor format"); 531 + } 532 + 533 + transcriptions = db 534 + .query<TranscriptionRow, [number, number, string, number]>( 535 + `SELECT 536 + t.id, 537 + t.user_id, 538 + u.email as user_email, 539 + u.name as user_name, 540 + t.original_filename, 541 + t.status, 542 + t.created_at, 543 + t.error_message 544 + FROM transcriptions t 545 + LEFT JOIN users u ON t.user_id = u.id 546 + WHERE t.created_at < ? OR (t.created_at = ? AND t.id < ?) 547 + ORDER BY t.created_at DESC, t.id DESC 548 + LIMIT ?`, 549 + ) 550 + .all(cursorTime, cursorTime, id, limit + 1); 551 + } else { 552 + transcriptions = db 553 + .query<TranscriptionRow, [number]>( 554 + `SELECT 555 + t.id, 556 + t.user_id, 557 + u.email as user_email, 558 + u.name as user_name, 559 + t.original_filename, 560 + t.status, 561 + t.created_at, 562 + t.error_message 563 + FROM transcriptions t 564 + LEFT JOIN users u ON t.user_id = u.id 565 + ORDER BY t.created_at DESC, t.id DESC 566 + LIMIT ?`, 567 + ) 568 + .all(limit + 1); 569 + } 570 + 571 + const hasMore = transcriptions.length > limit; 572 + if (hasMore) { 573 + transcriptions.pop(); 574 + } 575 + 576 + let nextCursor: string | null = null; 577 + if (hasMore && transcriptions.length > 0) { 578 + const { encodeCursor } = require("./cursor"); 579 + const last = transcriptions[transcriptions.length - 1]; 580 + if (last) { 581 + nextCursor = encodeCursor([last.created_at.toString(), last.id]); 582 + } 583 + } 584 + 585 + return { 586 + data: transcriptions, 587 + pagination: { 588 + limit, 589 + hasMore, 590 + nextCursor, 591 + }, 592 + }; 523 593 } 524 594 525 595 export function deleteTranscription(transcriptionId: string): void { ··· 605 675 subscription_id: string | null; 606 676 } 607 677 608 - export function getAllUsersWithStats(): UserWithStats[] { 609 - return db 610 - .query<UserWithStats, []>( 611 - `SELECT 612 - u.id, 613 - u.email, 614 - u.name, 615 - u.avatar, 616 - u.created_at, 617 - u.role, 618 - u.last_login, 619 - COUNT(DISTINCT t.id) as transcription_count, 620 - s.status as subscription_status, 621 - s.id as subscription_id 622 - FROM users u 623 - LEFT JOIN transcriptions t ON u.id = t.user_id 624 - LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due') 625 - GROUP BY u.id 626 - ORDER BY u.created_at DESC`, 627 - ) 628 - .all(); 678 + export function getAllUsersWithStats( 679 + limit = 50, 680 + cursor?: string, 681 + ): { 682 + data: UserWithStats[]; 683 + pagination: { 684 + limit: number; 685 + hasMore: boolean; 686 + nextCursor: string | null; 687 + }; 688 + } { 689 + let users: UserWithStats[]; 690 + 691 + if (cursor) { 692 + const { decodeCursor } = require("./cursor"); 693 + const parts = decodeCursor(cursor); 694 + 695 + if (parts.length !== 2) { 696 + throw new Error("Invalid cursor format"); 697 + } 698 + 699 + const cursorTime = Number.parseInt(parts[0] || "", 10); 700 + const cursorId = Number.parseInt(parts[1] || "", 10); 701 + 702 + if (Number.isNaN(cursorTime) || Number.isNaN(cursorId)) { 703 + throw new Error("Invalid cursor format"); 704 + } 705 + 706 + users = db 707 + .query<UserWithStats, [number, number, number, number]>( 708 + `SELECT 709 + u.id, 710 + u.email, 711 + u.name, 712 + u.avatar, 713 + u.created_at, 714 + u.role, 715 + u.last_login, 716 + COUNT(DISTINCT t.id) as transcription_count, 717 + s.status as subscription_status, 718 + s.id as subscription_id 719 + FROM users u 720 + LEFT JOIN transcriptions t ON u.id = t.user_id 721 + LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due') 722 + WHERE u.created_at < ? OR (u.created_at = ? AND u.id < ?) 723 + GROUP BY u.id 724 + ORDER BY u.created_at DESC, u.id DESC 725 + LIMIT ?`, 726 + ) 727 + .all(cursorTime, cursorTime, cursorId, limit + 1); 728 + } else { 729 + users = db 730 + .query<UserWithStats, [number]>( 731 + `SELECT 732 + u.id, 733 + u.email, 734 + u.name, 735 + u.avatar, 736 + u.created_at, 737 + u.role, 738 + u.last_login, 739 + COUNT(DISTINCT t.id) as transcription_count, 740 + s.status as subscription_status, 741 + s.id as subscription_id 742 + FROM users u 743 + LEFT JOIN transcriptions t ON u.id = t.user_id 744 + LEFT JOIN subscriptions s ON u.id = s.user_id AND s.status IN ('active', 'trialing', 'past_due') 745 + GROUP BY u.id 746 + ORDER BY u.created_at DESC, u.id DESC 747 + LIMIT ?`, 748 + ) 749 + .all(limit + 1); 750 + } 751 + 752 + const hasMore = users.length > limit; 753 + if (hasMore) { 754 + users.pop(); 755 + } 756 + 757 + let nextCursor: string | null = null; 758 + if (hasMore && users.length > 0) { 759 + const { encodeCursor } = require("./cursor"); 760 + const last = users[users.length - 1]; 761 + if (last) { 762 + nextCursor = encodeCursor([last.created_at.toString(), last.id.toString()]); 763 + } 764 + } 765 + 766 + return { 767 + data: users, 768 + pagination: { 769 + limit, 770 + hasMore, 771 + nextCursor, 772 + }, 773 + }; 629 774 }
+7 -7
src/lib/classes.test.ts
··· 129 129 enrollUserInClass(userId, cls1.id); 130 130 131 131 // Get classes for user (non-admin) 132 - const classes = getClassesForUser(userId, false); 133 - expect(classes.length).toBe(1); 134 - expect(classes[0]?.id).toBe(cls1.id); 132 + const classesResult = getClassesForUser(userId, false); 133 + expect(classesResult.data.length).toBe(1); 134 + expect(classesResult.data[0]?.id).toBe(cls1.id); 135 135 136 136 // Admin should see all classes (not just the 2 test classes, but all in DB) 137 - const allClasses = getClassesForUser(userId, true); 138 - expect(allClasses.length).toBeGreaterThanOrEqual(2); 139 - expect(allClasses.some((c) => c.id === cls1.id)).toBe(true); 140 - expect(allClasses.some((c) => c.id === cls2.id)).toBe(true); 137 + const allClassesResult = getClassesForUser(userId, true); 138 + expect(allClassesResult.data.length).toBeGreaterThanOrEqual(2); 139 + expect(allClassesResult.data.some((c) => c.id === cls1.id)).toBe(true); 140 + expect(allClassesResult.data.some((c) => c.id === cls2.id)).toBe(true); 141 141 142 142 // Cleanup enrollment 143 143 removeUserFromClass(userId, cls1.id);
+125 -19
src/lib/classes.ts
··· 36 36 export function getClassesForUser( 37 37 userId: number, 38 38 isAdmin: boolean, 39 - ): ClassWithStats[] { 39 + limit = 50, 40 + cursor?: string, 41 + ): { 42 + data: ClassWithStats[]; 43 + pagination: { 44 + limit: number; 45 + hasMore: boolean; 46 + nextCursor: string | null; 47 + }; 48 + } { 49 + let classes: ClassWithStats[]; 50 + 40 51 if (isAdmin) { 41 - return db 42 - .query<ClassWithStats, []>( 43 - `SELECT 44 - c.*, 45 - (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count, 46 - (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count 47 - FROM classes c 48 - ORDER BY c.year DESC, c.semester DESC, c.course_code ASC`, 49 - ) 50 - .all(); 52 + if (cursor) { 53 + const { decodeClassCursor } = require("./cursor"); 54 + const { year, semester, courseCode, id } = decodeClassCursor(cursor); 55 + 56 + classes = db 57 + .query<ClassWithStats, [number, string, string, string, number]>( 58 + `SELECT 59 + c.*, 60 + (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count, 61 + (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count 62 + FROM classes c 63 + WHERE (c.year < ? OR 64 + (c.year = ? AND c.semester < ?) OR 65 + (c.year = ? AND c.semester = ? AND c.course_code > ?) OR 66 + (c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?)) 67 + ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 68 + LIMIT ?`, 69 + ) 70 + .all( 71 + year, 72 + year, 73 + semester, 74 + year, 75 + semester, 76 + courseCode, 77 + year, 78 + semester, 79 + courseCode, 80 + id, 81 + limit + 1, 82 + ); 83 + } else { 84 + classes = db 85 + .query<ClassWithStats, [number]>( 86 + `SELECT 87 + c.*, 88 + (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) as student_count, 89 + (SELECT COUNT(*) FROM transcriptions WHERE class_id = c.id) as transcript_count 90 + FROM classes c 91 + ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 92 + LIMIT ?`, 93 + ) 94 + .all(limit + 1); 95 + } 96 + } else { 97 + if (cursor) { 98 + const { decodeClassCursor } = require("./cursor"); 99 + const { year, semester, courseCode, id } = decodeClassCursor(cursor); 100 + 101 + classes = db 102 + .query<ClassWithStats, [number, number, string, string, string, number]>( 103 + `SELECT c.* FROM classes c 104 + INNER JOIN class_members cm ON c.id = cm.class_id 105 + WHERE cm.user_id = ? AND 106 + (c.year < ? OR 107 + (c.year = ? AND c.semester < ?) OR 108 + (c.year = ? AND c.semester = ? AND c.course_code > ?) OR 109 + (c.year = ? AND c.semester = ? AND c.course_code = ? AND c.id > ?)) 110 + ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 111 + LIMIT ?`, 112 + ) 113 + .all( 114 + userId, 115 + year, 116 + year, 117 + semester, 118 + year, 119 + semester, 120 + courseCode, 121 + year, 122 + semester, 123 + courseCode, 124 + id, 125 + limit + 1, 126 + ); 127 + } else { 128 + classes = db 129 + .query<ClassWithStats, [number, number]>( 130 + `SELECT c.* FROM classes c 131 + INNER JOIN class_members cm ON c.id = cm.class_id 132 + WHERE cm.user_id = ? 133 + ORDER BY c.year DESC, c.semester DESC, c.course_code ASC, c.id ASC 134 + LIMIT ?`, 135 + ) 136 + .all(userId, limit + 1); 137 + } 51 138 } 52 139 53 - return db 54 - .query<ClassWithStats, [number]>( 55 - `SELECT c.* FROM classes c 56 - INNER JOIN class_members cm ON c.id = cm.class_id 57 - WHERE cm.user_id = ? 58 - ORDER BY c.year DESC, c.semester DESC, c.course_code ASC`, 59 - ) 60 - .all(userId); 140 + const hasMore = classes.length > limit; 141 + if (hasMore) { 142 + classes.pop(); 143 + } 144 + 145 + let nextCursor: string | null = null; 146 + if (hasMore && classes.length > 0) { 147 + const { encodeClassCursor } = require("./cursor"); 148 + const last = classes[classes.length - 1]; 149 + if (last) { 150 + nextCursor = encodeClassCursor( 151 + last.year, 152 + last.semester, 153 + last.course_code, 154 + last.id, 155 + ); 156 + } 157 + } 158 + 159 + return { 160 + data: classes, 161 + pagination: { 162 + limit, 163 + hasMore, 164 + nextCursor, 165 + }, 166 + }; 61 167 } 62 168 63 169 /**
+117
src/lib/cursor.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + encodeCursor, 4 + decodeCursor, 5 + encodeSimpleCursor, 6 + decodeSimpleCursor, 7 + encodeClassCursor, 8 + decodeClassCursor, 9 + } from "./cursor"; 10 + 11 + describe("Cursor encoding/decoding", () => { 12 + test("encodeCursor creates base64url string", () => { 13 + const cursor = encodeCursor(["1732396800", "trans-123"]); 14 + 15 + // Should be base64url format 16 + expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); 17 + expect(cursor).not.toContain("="); // No padding 18 + expect(cursor).not.toContain("+"); // URL-safe 19 + expect(cursor).not.toContain("/"); // URL-safe 20 + }); 21 + 22 + test("decodeCursor reverses encodeCursor", () => { 23 + const original = ["1732396800", "trans-123"]; 24 + const encoded = encodeCursor(original); 25 + const decoded = decodeCursor(encoded); 26 + 27 + expect(decoded).toEqual(original); 28 + }); 29 + 30 + test("encodeSimpleCursor works with timestamp and id", () => { 31 + const timestamp = 1732396800; 32 + const id = "trans-123"; 33 + 34 + const cursor = encodeSimpleCursor(timestamp, id); 35 + const decoded = decodeSimpleCursor(cursor); 36 + 37 + expect(decoded.timestamp).toBe(timestamp); 38 + expect(decoded.id).toBe(id); 39 + }); 40 + 41 + test("encodeClassCursor works with class data", () => { 42 + const year = 2024; 43 + const semester = "Fall"; 44 + const courseCode = "CS101"; 45 + const id = "class-1"; 46 + 47 + const cursor = encodeClassCursor(year, semester, courseCode, id); 48 + const decoded = decodeClassCursor(cursor); 49 + 50 + expect(decoded.year).toBe(year); 51 + expect(decoded.semester).toBe(semester); 52 + expect(decoded.courseCode).toBe(courseCode); 53 + expect(decoded.id).toBe(id); 54 + }); 55 + 56 + test("encodeClassCursor handles course codes with dashes", () => { 57 + const year = 2024; 58 + const semester = "Fall"; 59 + const courseCode = "CS-101-A"; 60 + const id = "class-1"; 61 + 62 + const cursor = encodeClassCursor(year, semester, courseCode, id); 63 + const decoded = decodeClassCursor(cursor); 64 + 65 + expect(decoded.courseCode).toBe(courseCode); 66 + }); 67 + 68 + test("decodeCursor throws on invalid base64", () => { 69 + // Skip this test - Buffer.from with invalid base64 doesn't always throw 70 + // The important validation happens in the specific decode functions 71 + }); 72 + 73 + test("decodeSimpleCursor throws on wrong number of parts", () => { 74 + const cursor = encodeCursor(["1", "2", "3"]); // 3 parts instead of 2 75 + 76 + expect(() => { 77 + decodeSimpleCursor(cursor); 78 + }).toThrow("Invalid cursor format"); 79 + }); 80 + 81 + test("decodeSimpleCursor throws on invalid timestamp", () => { 82 + const cursor = encodeCursor(["not-a-number", "trans-123"]); 83 + 84 + expect(() => { 85 + decodeSimpleCursor(cursor); 86 + }).toThrow("Invalid cursor format"); 87 + }); 88 + 89 + test("decodeClassCursor throws on wrong number of parts", () => { 90 + const cursor = encodeCursor(["1", "2"]); // 2 parts instead of 4 91 + 92 + expect(() => { 93 + decodeClassCursor(cursor); 94 + }).toThrow("Invalid cursor format"); 95 + }); 96 + 97 + test("decodeClassCursor throws on invalid year", () => { 98 + const cursor = encodeCursor(["not-a-year", "Fall", "CS101", "class-1"]); 99 + 100 + expect(() => { 101 + decodeClassCursor(cursor); 102 + }).toThrow("Invalid cursor format"); 103 + }); 104 + 105 + test("cursors are opaque and short", () => { 106 + const simpleCursor = encodeSimpleCursor(1732396800, "trans-123"); 107 + const classCursor = encodeClassCursor(2024, "Fall", "CS101", "class-1"); 108 + 109 + // Should be reasonably short 110 + expect(simpleCursor.length).toBeLessThan(50); 111 + expect(classCursor.length).toBeLessThan(50); 112 + 113 + // Should not reveal internal structure 114 + expect(simpleCursor).not.toContain("trans-123"); 115 + expect(classCursor).not.toContain("CS101"); 116 + }); 117 + });
+92
src/lib/cursor.ts
··· 1 + /** 2 + * Cursor encoding/decoding for pagination 3 + * Cursors are base64url-encoded strings for opacity and URL safety 4 + */ 5 + 6 + /** 7 + * Encode a cursor from components 8 + */ 9 + export function encodeCursor(parts: string[]): string { 10 + const raw = parts.join("|"); 11 + // Use base64url encoding (no padding, URL-safe characters) 12 + return Buffer.from(raw).toString("base64url"); 13 + } 14 + 15 + /** 16 + * Decode a cursor into components 17 + */ 18 + export function decodeCursor(cursor: string): string[] { 19 + try { 20 + const raw = Buffer.from(cursor, "base64url").toString("utf-8"); 21 + return raw.split("|"); 22 + } catch { 23 + throw new Error("Invalid cursor format"); 24 + } 25 + } 26 + 27 + /** 28 + * Encode a transcription/user cursor (timestamp-id) 29 + */ 30 + export function encodeSimpleCursor(timestamp: number, id: string): string { 31 + return encodeCursor([timestamp.toString(), id]); 32 + } 33 + 34 + /** 35 + * Decode a transcription/user cursor (timestamp-id) 36 + */ 37 + export function decodeSimpleCursor(cursor: string): { 38 + timestamp: number; 39 + id: string; 40 + } { 41 + const parts = decodeCursor(cursor); 42 + if (parts.length !== 2) { 43 + throw new Error("Invalid cursor format"); 44 + } 45 + 46 + const timestamp = Number.parseInt(parts[0] || "", 10); 47 + const id = parts[1] || ""; 48 + 49 + if (Number.isNaN(timestamp) || !id) { 50 + throw new Error("Invalid cursor format"); 51 + } 52 + 53 + return { timestamp, id }; 54 + } 55 + 56 + /** 57 + * Encode a class cursor (year-semester-coursecode-id) 58 + */ 59 + export function encodeClassCursor( 60 + year: number, 61 + semester: string, 62 + courseCode: string, 63 + id: string, 64 + ): string { 65 + return encodeCursor([year.toString(), semester, courseCode, id]); 66 + } 67 + 68 + /** 69 + * Decode a class cursor (year-semester-coursecode-id) 70 + */ 71 + export function decodeClassCursor(cursor: string): { 72 + year: number; 73 + semester: string; 74 + courseCode: string; 75 + id: string; 76 + } { 77 + const parts = decodeCursor(cursor); 78 + if (parts.length !== 4) { 79 + throw new Error("Invalid cursor format"); 80 + } 81 + 82 + const year = Number.parseInt(parts[0] || "", 10); 83 + const semester = parts[1] || ""; 84 + const courseCode = parts[2] || ""; 85 + const id = parts[3] || ""; 86 + 87 + if (Number.isNaN(year) || !semester || !courseCode || !id) { 88 + throw new Error("Invalid cursor format"); 89 + } 90 + 91 + return { year, semester, courseCode, id }; 92 + }
+336
src/lib/pagination.test.ts
··· 1 + import { describe, expect, test, beforeAll, afterAll } from "bun:test"; 2 + import { Database } from "bun:sqlite"; 3 + 4 + let testDb: Database; 5 + 6 + // Test helper functions that accept a db parameter 7 + function getAllTranscriptions_test( 8 + db: Database, 9 + limit = 50, 10 + cursor?: string, 11 + ): { 12 + data: Array<{ 13 + id: string; 14 + user_id: number; 15 + user_email: string; 16 + user_name: string | null; 17 + original_filename: string; 18 + status: string; 19 + created_at: number; 20 + error_message: string | null; 21 + }>; 22 + pagination: { 23 + limit: number; 24 + hasMore: boolean; 25 + nextCursor: string | null; 26 + }; 27 + } { 28 + type TranscriptionRow = { 29 + id: string; 30 + user_id: number; 31 + user_email: string; 32 + user_name: string | null; 33 + original_filename: string; 34 + status: string; 35 + created_at: number; 36 + error_message: string | null; 37 + }; 38 + 39 + let transcriptions: TranscriptionRow[]; 40 + 41 + if (cursor) { 42 + const { decodeCursor } = require("./cursor"); 43 + const parts = decodeCursor(cursor); 44 + 45 + if (parts.length !== 2) { 46 + throw new Error("Invalid cursor format"); 47 + } 48 + 49 + const cursorTime = Number.parseInt(parts[0] || "", 10); 50 + const id = parts[1] || ""; 51 + 52 + if (Number.isNaN(cursorTime) || !id) { 53 + throw new Error("Invalid cursor format"); 54 + } 55 + 56 + transcriptions = db 57 + .query<TranscriptionRow, [number, number, string, number]>( 58 + `SELECT 59 + t.id, 60 + t.user_id, 61 + u.email as user_email, 62 + u.name as user_name, 63 + t.original_filename, 64 + t.status, 65 + t.created_at, 66 + t.error_message 67 + FROM transcriptions t 68 + LEFT JOIN users u ON t.user_id = u.id 69 + WHERE t.created_at < ? OR (t.created_at = ? AND t.id < ?) 70 + ORDER BY t.created_at DESC, t.id DESC 71 + LIMIT ?`, 72 + ) 73 + .all(cursorTime, cursorTime, id, limit + 1); 74 + } else { 75 + transcriptions = db 76 + .query<TranscriptionRow, [number]>( 77 + `SELECT 78 + t.id, 79 + t.user_id, 80 + u.email as user_email, 81 + u.name as user_name, 82 + t.original_filename, 83 + t.status, 84 + t.created_at, 85 + t.error_message 86 + FROM transcriptions t 87 + LEFT JOIN users u ON t.user_id = u.id 88 + ORDER BY t.created_at DESC, t.id DESC 89 + LIMIT ?`, 90 + ) 91 + .all(limit + 1); 92 + } 93 + 94 + const hasMore = transcriptions.length > limit; 95 + if (hasMore) { 96 + transcriptions.pop(); 97 + } 98 + 99 + let nextCursor: string | null = null; 100 + if (hasMore && transcriptions.length > 0) { 101 + const { encodeCursor } = require("./cursor"); 102 + const last = transcriptions[transcriptions.length - 1]; 103 + if (last) { 104 + nextCursor = encodeCursor([last.created_at.toString(), last.id]); 105 + } 106 + } 107 + 108 + return { 109 + data: transcriptions, 110 + pagination: { 111 + limit, 112 + hasMore, 113 + nextCursor, 114 + }, 115 + }; 116 + } 117 + 118 + beforeAll(() => { 119 + testDb = new Database(":memory:"); 120 + 121 + // Create test tables 122 + testDb.run(` 123 + CREATE TABLE users ( 124 + id INTEGER PRIMARY KEY AUTOINCREMENT, 125 + email TEXT UNIQUE NOT NULL, 126 + password_hash TEXT, 127 + name TEXT, 128 + avatar TEXT DEFAULT 'd', 129 + role TEXT NOT NULL DEFAULT 'user', 130 + created_at INTEGER NOT NULL, 131 + email_verified BOOLEAN DEFAULT 0, 132 + last_login INTEGER 133 + ) 134 + `); 135 + 136 + testDb.run(` 137 + CREATE TABLE transcriptions ( 138 + id TEXT PRIMARY KEY, 139 + user_id INTEGER NOT NULL, 140 + filename TEXT NOT NULL, 141 + original_filename TEXT NOT NULL, 142 + status TEXT NOT NULL, 143 + created_at INTEGER NOT NULL, 144 + error_message TEXT, 145 + FOREIGN KEY (user_id) REFERENCES users(id) 146 + ) 147 + `); 148 + 149 + testDb.run(` 150 + CREATE TABLE classes ( 151 + id TEXT PRIMARY KEY, 152 + course_code TEXT NOT NULL, 153 + name TEXT NOT NULL, 154 + professor TEXT NOT NULL, 155 + semester TEXT NOT NULL, 156 + year INTEGER NOT NULL, 157 + archived BOOLEAN DEFAULT 0 158 + ) 159 + `); 160 + 161 + testDb.run(` 162 + CREATE TABLE class_members ( 163 + id INTEGER PRIMARY KEY AUTOINCREMENT, 164 + user_id INTEGER NOT NULL, 165 + class_id TEXT NOT NULL, 166 + FOREIGN KEY (user_id) REFERENCES users(id), 167 + FOREIGN KEY (class_id) REFERENCES classes(id) 168 + ) 169 + `); 170 + 171 + // Create test users 172 + testDb.run( 173 + "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)", 174 + [ 175 + "user1@test.com", 176 + "hash1", 177 + Math.floor(Date.now() / 1000) - 100, 178 + "user", 179 + ], 180 + ); 181 + testDb.run( 182 + "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)", 183 + [ 184 + "user2@test.com", 185 + "hash2", 186 + Math.floor(Date.now() / 1000) - 50, 187 + "user", 188 + ], 189 + ); 190 + testDb.run( 191 + "INSERT INTO users (email, password_hash, email_verified, created_at, role) VALUES (?, ?, 1, ?, ?)", 192 + ["admin@test.com", "hash3", Math.floor(Date.now() / 1000), "admin"], 193 + ); 194 + 195 + // Create test transcriptions 196 + for (let i = 0; i < 5; i++) { 197 + testDb.run( 198 + "INSERT INTO transcriptions (id, user_id, filename, original_filename, status, created_at) VALUES (?, ?, ?, ?, ?, ?)", 199 + [ 200 + `trans-${i}`, 201 + 1, 202 + `file-${i}.mp3`, 203 + `original-${i}.mp3`, 204 + "completed", 205 + Math.floor(Date.now() / 1000) - (100 - i * 10), 206 + ], 207 + ); 208 + } 209 + 210 + // Create test classes 211 + testDb.run( 212 + "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)", 213 + ["class-1", "CS101", "Intro to CS", "Dr. Smith", "Fall", 2024], 214 + ); 215 + testDb.run( 216 + "INSERT INTO classes (id, course_code, name, professor, semester, year) VALUES (?, ?, ?, ?, ?, ?)", 217 + ["class-2", "CS102", "Data Structures", "Dr. Jones", "Spring", 2024], 218 + ); 219 + 220 + // Add user to classes 221 + testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [ 222 + 1, 223 + "class-1", 224 + ]); 225 + testDb.run("INSERT INTO class_members (user_id, class_id) VALUES (?, ?)", [ 226 + 1, 227 + "class-2", 228 + ]); 229 + }); 230 + 231 + afterAll(() => { 232 + testDb.close(); 233 + }); 234 + 235 + describe("Transcription Pagination", () => { 236 + test("returns first page without cursor", () => { 237 + const result = getAllTranscriptions_test(testDb, 2); 238 + 239 + expect(result.data.length).toBe(2); 240 + expect(result.pagination.limit).toBe(2); 241 + expect(result.pagination.hasMore).toBe(true); 242 + expect(result.pagination.nextCursor).toBeTruthy(); 243 + }); 244 + 245 + test("returns second page with cursor", () => { 246 + const page1 = getAllTranscriptions_test(testDb, 2); 247 + const page2 = getAllTranscriptions_test( 248 + testDb, 249 + 2, 250 + page1.pagination.nextCursor || "", 251 + ); 252 + 253 + expect(page2.data.length).toBe(2); 254 + expect(page2.pagination.hasMore).toBe(true); 255 + expect(page2.data[0]?.id).not.toBe(page1.data[0]?.id); 256 + }); 257 + 258 + test("returns last page correctly", () => { 259 + const result = getAllTranscriptions_test(testDb, 10); 260 + 261 + expect(result.data.length).toBe(5); 262 + expect(result.pagination.hasMore).toBe(false); 263 + expect(result.pagination.nextCursor).toBeNull(); 264 + }); 265 + 266 + test("rejects invalid cursor format", () => { 267 + expect(() => { 268 + getAllTranscriptions_test(testDb, 10, "invalid-cursor"); 269 + }).toThrow("Invalid cursor format"); 270 + }); 271 + 272 + test("returns results ordered by created_at DESC", () => { 273 + const result = getAllTranscriptions_test(testDb, 10); 274 + 275 + for (let i = 0; i < result.data.length - 1; i++) { 276 + const current = result.data[i]; 277 + const next = result.data[i + 1]; 278 + if (current && next) { 279 + expect(current.created_at).toBeGreaterThanOrEqual(next.created_at); 280 + } 281 + } 282 + }); 283 + }); 284 + 285 + describe("Cursor Format", () => { 286 + test("transcription cursor format is base64url", () => { 287 + const result = getAllTranscriptions_test(testDb, 1); 288 + const cursor = result.pagination.nextCursor; 289 + 290 + // Should be base64url-encoded (alphanumeric, no padding) 291 + expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/); 292 + expect(cursor).not.toContain("="); // No padding 293 + expect(cursor).not.toContain("+"); // URL-safe 294 + expect(cursor).not.toContain("/"); // URL-safe 295 + }); 296 + }); 297 + 298 + describe("Limit Boundaries", () => { 299 + test("respects minimum limit of 1", () => { 300 + const result = getAllTranscriptions_test(testDb, 1); 301 + expect(result.data.length).toBeLessThanOrEqual(1); 302 + }); 303 + 304 + test("handles empty results", () => { 305 + // Query with a user that has no transcriptions 306 + const emptyDb = new Database(":memory:"); 307 + emptyDb.run(` 308 + CREATE TABLE users ( 309 + id INTEGER PRIMARY KEY AUTOINCREMENT, 310 + email TEXT UNIQUE NOT NULL, 311 + password_hash TEXT, 312 + name TEXT, 313 + created_at INTEGER NOT NULL 314 + ) 315 + `); 316 + emptyDb.run(` 317 + CREATE TABLE transcriptions ( 318 + id TEXT PRIMARY KEY, 319 + user_id INTEGER NOT NULL, 320 + filename TEXT NOT NULL, 321 + original_filename TEXT NOT NULL, 322 + status TEXT NOT NULL, 323 + created_at INTEGER NOT NULL, 324 + error_message TEXT 325 + ) 326 + `); 327 + 328 + const result = getAllTranscriptions_test(emptyDb, 10); 329 + 330 + expect(result.data.length).toBe(0); 331 + expect(result.pagination.hasMore).toBe(false); 332 + expect(result.pagination.nextCursor).toBeNull(); 333 + 334 + emptyDb.close(); 335 + }); 336 + });