From 50f4a44fff691036fb79f32e28bed400ede5f5dc Mon Sep 17 00:00:00 2001 From: rogutkuba Date: Sat, 25 Jul 2026 18:30:42 -0400 Subject: [PATCH 1/2] perf: speed up assessment authoring and management flows Address the two slowness complaints from churn feedback (creating tests, managing assessments) at their root: - Add DB indexes on the FK/org columns every assessment query filters and joins on (assessments, assessment_questions, question_library, question_library_test_cases, question_submissions, test_case_results). These were sequential scans before. Applied via `db:push`. - Fix listSubmissions N+1: fetch candidates in one inArray query instead of one round-trip per submission. - Stop the full-assessment refetch on every authoring edit: test-case add/edit/delete now patch the query cache from the mutation response, and question reorder is optimistic. Signature-affecting mutations still invalidate (server cascades test-case deletion). - Batch remaining N+1 loops: inviteCandidate inserts, getSubmissionDetails, getSubmissionByToken, and computeWeightedAggregate. Co-Authored-By: Claude Opus 4.8 --- .../services/AssessmentSubmission.service.ts | 220 +++++++++++------- apps/web/src/query/assessment.query.ts | 100 +++++++- packages/db/src/assessment.db.ts | 54 +++-- packages/db/src/assessmentQuestion.db.ts | 49 ++-- packages/db/src/questionLibrary.db.ts | 54 +++-- packages/db/src/questionLibraryTestCase.db.ts | 46 ++-- packages/db/src/questionSubmission.db.ts | 54 +++-- packages/db/src/testCaseResult.db.ts | 51 ++-- 8 files changed, 394 insertions(+), 234 deletions(-) diff --git a/apps/api/src/services/AssessmentSubmission.service.ts b/apps/api/src/services/AssessmentSubmission.service.ts index b00d784..6bed36d 100644 --- a/apps/api/src/services/AssessmentSubmission.service.ts +++ b/apps/api/src/services/AssessmentSubmission.service.ts @@ -166,23 +166,27 @@ export class AssessmentSubmissionService { .returning() .then((r) => r[0]); - // Create questionSubmission rows for each assessment question link + // Create questionSubmission rows for each assessment question link in a + // single batch insert rather than one round-trip per question. const questions = await this.db .select() .from(assessmentQuestionTable) .where(eq(assessmentQuestionTable.assessmentId, assessmentId)) .orderBy(asc(assessmentQuestionTable.position)); - for (const q of questions) { - await this.db.insert(questionSubmissionTable).values({ - id: generateId('questionSubmission'), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - submissionId: submissionId, - questionId: q.id, - organizationId: orgId, - isDraft: true, - }); + if (questions.length > 0) { + const now = new Date().toISOString(); + await this.db.insert(questionSubmissionTable).values( + questions.map((q) => ({ + id: generateId('questionSubmission'), + createdAt: now, + updatedAt: now, + submissionId: submissionId, + questionId: q.id, + organizationId: orgId, + isDraft: true, + })) + ); } // Email the candidate their invitation (fire-and-forget). The access link is @@ -249,17 +253,16 @@ export class AssessmentSubmissionService { ) .orderBy(desc(assessmentSubmissionTable.createdAt)); - // Join with candidates + // Join with candidates. Fetch every referenced candidate in a single query + // rather than one round-trip per submission. const candidateIds = [...new Set(submissions.map((s) => s.candidateId))] as Id<'candidate'>[]; - const candidates: CandidateEntity[] = []; - for (const cId of candidateIds) { - const c = await this.db - .select() - .from(candidateTable) - .where(eq(candidateTable.id, cId)) - .then((r) => (r.length > 0 ? r[0] : null)); - if (c) candidates.push(c); - } + const candidates: CandidateEntity[] = + candidateIds.length > 0 + ? await this.db + .select() + .from(candidateTable) + .where(inArray(candidateTable.id, candidateIds)) + : []; const candidateMap = new Map(candidates.map((c) => [c.id, c])); @@ -309,12 +312,9 @@ export class AssessmentSubmissionService { .from(questionSubmissionTable) .where(eq(questionSubmissionTable.submissionId, submissionId)); - // One entry per assessment question: the best non-draft submission (the - // graded attempt the candidate actually submitted) enriched with the test - // case definitions, falling back to the draft (their latest editor code) if - // they never submitted. - const questions = []; - for (const l of links) { + // Resolve the best/draft submission per assessment question up front so we + // know which questionSubmission rows carry test results worth fetching. + const perQuestion = links.map((l) => { const aqId = l.link.id; const forQuestion = allQuestionSubmissions.filter((qs) => qs.questionId === aqId); const best = @@ -324,6 +324,48 @@ export class AssessmentSubmissionService { (a, b) => (b.score ?? -1) - (a.score ?? -1) || (a.createdAt < b.createdAt ? 1 : -1) )[0] ?? null; const draft = forQuestion.find((qs) => qs.isDraft) ?? null; + return { link: l, best, draft }; + }); + + // Batch-fetch every test result for all "best" submissions in one query, + // then the test case definitions they reference in a second query, rather + // than two round-trips per question. + const bestIds = perQuestion + .map((p) => p.best?.id) + .filter((id): id is Id<'questionSubmission'> => Boolean(id)); + const allResults = + bestIds.length > 0 + ? await this.db + .select() + .from(testCaseResultTable) + .where(inArray(testCaseResultTable.questionSubmissionId, bestIds)) + : []; + + const allDefIds = [ + ...new Set(allResults.map((r) => r.testCaseId as Id<'questionLibraryTestCase'>)), + ]; + const allDefs = + allDefIds.length > 0 + ? await this.db + .select() + .from(questionLibraryTestCaseTable) + .where(inArray(questionLibraryTestCaseTable.id, allDefIds)) + : []; + const defById = new Map(allDefs.map((d) => [d.id, d])); + + const resultsByQuestionSubmissionId = new Map(); + for (const r of allResults) { + const existing = resultsByQuestionSubmissionId.get(r.questionSubmissionId) || []; + existing.push(r); + resultsByQuestionSubmissionId.set(r.questionSubmissionId, existing); + } + + // One entry per assessment question: the best non-draft submission (the + // graded attempt the candidate actually submitted) enriched with the test + // case definitions, falling back to the draft (their latest editor code) if + // they never submitted. + const questions = perQuestion.map(({ link: l, best, draft }) => { + const aqId = l.link.id; const chosen = best ?? draft; // Per-test results, enriched with the test case definition (label, args, @@ -338,19 +380,7 @@ export class AssessmentSubmissionService { } > = []; if (best) { - const results = await this.db - .select() - .from(testCaseResultTable) - .where(eq(testCaseResultTable.questionSubmissionId, best.id)); - const testCaseIds = results.map((r) => r.testCaseId as Id<'questionLibraryTestCase'>); - const defs = - testCaseIds.length > 0 - ? await this.db - .select() - .from(questionLibraryTestCaseTable) - .where(inArray(questionLibraryTestCaseTable.id, testCaseIds)) - : []; - const defById = new Map(defs.map((d) => [d.id, d])); + const results = resultsByQuestionSubmissionId.get(best.id) ?? []; testCaseResults = results .map((r) => { const def = defById.get(r.testCaseId as Id<'questionLibraryTestCase'>); @@ -366,7 +396,7 @@ export class AssessmentSubmissionService { .sort((a, b) => a.position - b.position); } - questions.push({ + return { assessmentQuestionId: aqId, title: l.question.title, position: l.link.position, @@ -377,8 +407,8 @@ export class AssessmentSubmissionService { score: best?.score ?? null, maxScore: best?.maxScore ?? null, testCaseResults, - }); - } + }; + }); return { ...submission, @@ -491,25 +521,33 @@ export class AssessmentSubmissionService { .from(assessmentQuestionTable) .where(eq(assessmentQuestionTable.assessmentId, submission.assessmentId as Id<'assessment'>)); + // Fetch every non-draft submission for this attempt in one query (ordered + // by score desc), then keep the highest-scoring one per question in memory. + const nonDraftSubmissions = await this.db + .select() + .from(questionSubmissionTable) + .where( + and( + eq(questionSubmissionTable.submissionId, submissionId), + eq(questionSubmissionTable.isDraft, false) + ) + ) + .orderBy(desc(questionSubmissionTable.score)); + + const bestByQuestionId = new Map(); + for (const qs of nonDraftSubmissions) { + if (!bestByQuestionId.has(qs.questionId)) { + bestByQuestionId.set(qs.questionId, qs); + } + } + let weightedTotal = 0; let weightedMax = 0; for (const aq of assessmentQuestions) { weightedMax += aq.points; - const bestSubmission = await this.db - .select() - .from(questionSubmissionTable) - .where( - and( - eq(questionSubmissionTable.submissionId, submissionId), - eq(questionSubmissionTable.questionId, aq.id), - eq(questionSubmissionTable.isDraft, false) - ) - ) - .orderBy(desc(questionSubmissionTable.score)) - .limit(1) - .then((r) => (r.length > 0 ? r[0] : null)); + const bestSubmission = bestByQuestionId.get(aq.id) ?? null; if (bestSubmission?.maxScore && bestSubmission.maxScore > 0) { const ratio = (bestSubmission.score ?? 0) / bestSubmission.maxScore; @@ -582,49 +620,59 @@ export class AssessmentSubmissionService { .where(eq(assessmentQuestionTable.assessmentId, assessment.id)) .orderBy(asc(assessmentQuestionTable.position)); - // Best non-draft submission per assessment question for this submission (highest score wins) - const nonDraftSubmissions = await this.db + // All question submissions for this attempt in one query. Used both to pick + // the best non-draft per question (highest score wins) and to surface the + // candidate's current submission row per question. + const allSubmissions = await this.db .select() .from(questionSubmissionTable) - .where( - and( - eq(questionSubmissionTable.submissionId, submission.id), - eq(questionSubmissionTable.isDraft, false) - ) - ) + .where(eq(questionSubmissionTable.submissionId, submission.id)) .orderBy(desc(questionSubmissionTable.score)); const bestByQuestionId = new Map(); - for (const qs of nonDraftSubmissions) { - if (!bestByQuestionId.has(qs.questionId)) { + const submissionByQuestionId = new Map(); + for (const qs of allSubmissions) { + if (!qs.isDraft && !bestByQuestionId.has(qs.questionId)) { bestByQuestionId.set(qs.questionId, { score: qs.score, maxScore: qs.maxScore }); } + // Surface the draft row (the candidate's live editor code) when present, + // otherwise fall back to any submission for the question. + const existing = submissionByQuestionId.get(qs.questionId); + if (!existing || (qs.isDraft && !existing.isDraft)) { + submissionByQuestionId.set(qs.questionId, qs); + } + } + + // All visible test cases for every question on the assessment in one query, + // grouped by library question id. + const libraryQuestionIds = rows.map((r) => r.question.id); + const allVisibleTestCases = + libraryQuestionIds.length > 0 + ? await this.db + .select() + .from(questionLibraryTestCaseTable) + .where( + and( + inArray(questionLibraryTestCaseTable.questionId, libraryQuestionIds), + eq(questionLibraryTestCaseTable.isHidden, false) + ) + ) + .orderBy(asc(questionLibraryTestCaseTable.position)) + : []; + + const testCasesByQuestionId = new Map(); + for (const tc of allVisibleTestCases) { + const existing = testCasesByQuestionId.get(tc.questionId) || []; + existing.push(tc); + testCasesByQuestionId.set(tc.questionId, existing); } const questionsWithData = []; for (const r of rows) { // Only visible test cases for candidate - const testCases = await this.db - .select() - .from(questionLibraryTestCaseTable) - .where( - and( - eq(questionLibraryTestCaseTable.questionId, r.question.id), - eq(questionLibraryTestCaseTable.isHidden, false) - ) - ) - .orderBy(asc(questionLibraryTestCaseTable.position)); + const testCases = testCasesByQuestionId.get(r.question.id) ?? []; - const questionSubmission = await this.db - .select() - .from(questionSubmissionTable) - .where( - and( - eq(questionSubmissionTable.submissionId, submission.id), - eq(questionSubmissionTable.questionId, r.link.id) - ) - ) - .then((rows) => (rows.length > 0 ? rows[0] : null)); + const questionSubmission = submissionByQuestionId.get(r.link.id) ?? null; const best = bestByQuestionId.get(r.link.id) ?? null; diff --git a/apps/web/src/query/assessment.query.ts b/apps/web/src/query/assessment.query.ts index 69d8e0e..e744107 100644 --- a/apps/web/src/query/assessment.query.ts +++ b/apps/web/src/query/assessment.query.ts @@ -9,10 +9,37 @@ import type { UpdateQuestionSchema, UpdateTestCaseSchema, } from '@coderscreen/api/schema/assessment'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { type QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { throwApiError } from '@/query/error.query'; import { apiClient } from './client'; +// Surgically update the cached assessment(s) for `assessmentId` without a +// refetch. `useAssessment` caches under `['assessments', id, { ...pagination }]`, +// so we match every pagination variant by predicate and rewrite its `questions` +// array. This keeps test-case edits from re-pulling the entire assessment +// (all questions + all test cases) on every keystroke-level save. +// biome-ignore lint/suspicious/noExplicitAny: cache payloads are loosely typed here (see useAssessment) +type CachedQuestion = any; +const patchAssessmentQuestions = ( + queryClient: QueryClient, + assessmentId: string, + updater: (questions: CachedQuestion[]) => CachedQuestion[] +) => { + queryClient.setQueriesData( + { + predicate: (query) => + Array.isArray(query.queryKey) && + query.queryKey[0] === 'assessments' && + query.queryKey[1] === assessmentId, + }, + // biome-ignore lint/suspicious/noExplicitAny: cache payloads are loosely typed here + (old: any) => { + if (!old || !Array.isArray(old.questions)) return old; + return { ...old, questions: updater(old.questions) }; + } + ); +}; + // ============================================================ // Assessments // ============================================================ @@ -336,8 +363,28 @@ export const useReorderQuestions = (assessmentId: string) => { } return response.json(); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['assessments', assessmentId] }); + // Reorder is client-authoritative (only `position` changes, nothing is + // server-derived), so apply it optimistically and skip the refetch. On + // error we roll back to the pre-drag snapshot. + onMutate: async (order) => { + const predicate = (query: { queryKey: unknown }) => + Array.isArray(query.queryKey) && + query.queryKey[0] === 'assessments' && + query.queryKey[1] === assessmentId; + await queryClient.cancelQueries({ predicate }); + const snapshots = queryClient.getQueriesData({ predicate }); + + const positionById = new Map(order.map((o) => [o.id, o.position])); + patchAssessmentQuestions(queryClient, assessmentId, (questions) => + questions + .map((q) => (positionById.has(q.id) ? { ...q, position: positionById.get(q.id) } : q)) + .sort((a, b) => a.position - b.position) + ); + + return { snapshots }; + }, + onError: (_err, _order, context) => { + context?.snapshots?.forEach(([key, data]) => queryClient.setQueryData(key, data)); }, meta: { ERROR_MESSAGE: 'Failed to reorder questions', @@ -401,8 +448,19 @@ export const useCreateTestCase = (assessmentId: string, questionId: string) => { } return response.json(); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['assessments', assessmentId] }); + onSuccess: (newTestCase) => { + patchAssessmentQuestions(queryClient, assessmentId, (questions) => + questions.map((q) => + q.id === questionId + ? { + ...q, + testCases: [...(q.testCases ?? []), newTestCase].sort( + (a, b) => a.position - b.position + ), + } + : q + ) + ); }, meta: { SUCCESS_MESSAGE: 'Test case added', @@ -439,8 +497,23 @@ export const useUpdateTestCase = (assessmentId: string, questionId: string) => { } return response.json(); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['assessments', assessmentId] }); + onSuccess: (updatedTestCase) => { + patchAssessmentQuestions(queryClient, assessmentId, (questions) => + questions.map((q) => + q.id === questionId + ? { + ...q, + testCases: (q.testCases ?? []) + .map((tc: { id: string }) => + tc.id === updatedTestCase.id ? updatedTestCase : tc + ) + .sort( + (a: { position: number }, b: { position: number }) => a.position - b.position + ), + } + : q + ) + ); }, meta: { SUCCESS_MESSAGE: 'Test case updated', @@ -470,8 +543,17 @@ export const useDeleteTestCase = (assessmentId: string, questionId: string) => { } return response.json(); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['assessments', assessmentId] }); + onSuccess: (_data, testCaseId) => { + patchAssessmentQuestions(queryClient, assessmentId, (questions) => + questions.map((q) => + q.id === questionId + ? { + ...q, + testCases: (q.testCases ?? []).filter((tc: { id: string }) => tc.id !== testCaseId), + } + : q + ) + ); }, meta: { SUCCESS_MESSAGE: 'Test case deleted', diff --git a/packages/db/src/assessment.db.ts b/packages/db/src/assessment.db.ts index 9f43716..9927a92 100644 --- a/packages/db/src/assessment.db.ts +++ b/packages/db/src/assessment.db.ts @@ -1,6 +1,6 @@ import type { Id } from '@coderscreen/common/id'; import { sql } from 'drizzle-orm'; -import { integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { organization, user } from './user.db'; export type AssessmentMode = 'sequential' | 'independent'; @@ -18,29 +18,33 @@ export type AssessmentLanguage = | 'php' | 'ruby'; -export const assessmentTable = pgTable('assessments', { - id: text('id').primaryKey().$type>(), - createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - organizationId: text('organization_id') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - createdByUserId: text('created_by_user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - title: text('title').notNull(), - description: text('description').notNull().default(''), - mode: text('mode').$type().notNull().default('independent'), - status: text('status').$type().notNull().default('draft'), - allowedLanguages: jsonb('allowed_languages') - .$type() - .notNull() - .default(['python', 'javascript', 'typescript']), - timeLimitSeconds: integer('time_limit_seconds'), -}); +export const assessmentTable = pgTable( + 'assessments', + { + id: text('id').primaryKey().$type>(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + createdByUserId: text('created_by_user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + title: text('title').notNull(), + description: text('description').notNull().default(''), + mode: text('mode').$type().notNull().default('independent'), + status: text('status').$type().notNull().default('draft'), + allowedLanguages: jsonb('allowed_languages') + .$type() + .notNull() + .default(['python', 'javascript', 'typescript']), + timeLimitSeconds: integer('time_limit_seconds'), + }, + (t) => [index('idx_assessment_org').on(t.organizationId)] +); export type AssessmentEntity = typeof assessmentTable.$inferSelect; diff --git a/packages/db/src/assessmentQuestion.db.ts b/packages/db/src/assessmentQuestion.db.ts index 9450f05..da0553a 100644 --- a/packages/db/src/assessmentQuestion.db.ts +++ b/packages/db/src/assessmentQuestion.db.ts @@ -1,29 +1,36 @@ import type { Id } from '@coderscreen/common/id'; import { sql } from 'drizzle-orm'; -import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { assessmentTable } from './assessment.db'; import { questionLibraryTable } from './questionLibrary.db'; import { organization } from './user.db'; -export const assessmentQuestionTable = pgTable('assessment_questions', { - id: text('id').primaryKey().$type>(), - createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - assessmentId: text('assessment_id') - .notNull() - .references(() => assessmentTable.id, { onDelete: 'cascade' }), - organizationId: text('organization_id') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - questionId: text('question_id') - .notNull() - .references(() => questionLibraryTable.id, { onDelete: 'cascade' }), - position: integer('position').notNull(), - points: integer('points').notNull().default(100), -}); +export const assessmentQuestionTable = pgTable( + 'assessment_questions', + { + id: text('id').primaryKey().$type>(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + assessmentId: text('assessment_id') + .notNull() + .references(() => assessmentTable.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + questionId: text('question_id') + .notNull() + .references(() => questionLibraryTable.id, { onDelete: 'cascade' }), + position: integer('position').notNull(), + points: integer('points').notNull().default(100), + }, + (t) => [ + index('idx_assessment_question_assessment').on(t.assessmentId), + index('idx_assessment_question_question').on(t.questionId), + ] +); export type AssessmentQuestionEntity = typeof assessmentQuestionTable.$inferSelect; diff --git a/packages/db/src/questionLibrary.db.ts b/packages/db/src/questionLibrary.db.ts index 7a3d23c..599451a 100644 --- a/packages/db/src/questionLibrary.db.ts +++ b/packages/db/src/questionLibrary.db.ts @@ -1,33 +1,37 @@ import type { Id } from '@coderscreen/common/id'; import type { Parameter, TypeString } from '@coderscreen/common/types'; import { sql } from 'drizzle-orm'; -import { boolean, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import type { AssessmentLanguage } from './assessment.db'; import { organization, user } from './user.db'; -export const questionLibraryTable = pgTable('question_library', { - id: text('id').primaryKey().$type>(), - createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - organizationId: text('organization_id').references(() => organization.id, { - onDelete: 'cascade', - }), - createdByUserId: text('created_by_user_id').references(() => user.id, { onDelete: 'set null' }), - title: text('title').notNull(), - description: jsonb('description').notNull(), - functionName: text('function_name').notNull().default(''), - parameters: jsonb('parameters').$type().notNull().default([]), - returnType: text('return_type').$type().notNull().default('null'), - starterCode: jsonb('starter_code') - .$type>>() - .notNull() - .default({}), - timeLimitSeconds: integer('time_limit_seconds'), - isPublic: boolean('is_public').notNull().default(false), -}); +export const questionLibraryTable = pgTable( + 'question_library', + { + id: text('id').primaryKey().$type>(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + organizationId: text('organization_id').references(() => organization.id, { + onDelete: 'cascade', + }), + createdByUserId: text('created_by_user_id').references(() => user.id, { onDelete: 'set null' }), + title: text('title').notNull(), + description: jsonb('description').notNull(), + functionName: text('function_name').notNull().default(''), + parameters: jsonb('parameters').$type().notNull().default([]), + returnType: text('return_type').$type().notNull().default('null'), + starterCode: jsonb('starter_code') + .$type>>() + .notNull() + .default({}), + timeLimitSeconds: integer('time_limit_seconds'), + isPublic: boolean('is_public').notNull().default(false), + }, + (t) => [index('idx_question_library_org').on(t.organizationId)] +); export type QuestionLibraryEntity = typeof questionLibraryTable.$inferSelect; diff --git a/packages/db/src/questionLibraryTestCase.db.ts b/packages/db/src/questionLibraryTestCase.db.ts index df8d5f2..add444d 100644 --- a/packages/db/src/questionLibraryTestCase.db.ts +++ b/packages/db/src/questionLibraryTestCase.db.ts @@ -1,27 +1,31 @@ import type { Id } from '@coderscreen/common/id'; import { sql } from 'drizzle-orm'; -import { boolean, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, index, integer, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { questionLibraryTable } from './questionLibrary.db'; -export const questionLibraryTestCaseTable = pgTable('question_library_test_cases', { - id: text('id').primaryKey().$type>(), - createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - questionId: text('question_id') - .notNull() - .references(() => questionLibraryTable.id, { onDelete: 'cascade' }), - label: text('label').notNull().default(''), - // Positional arg values, one entry per question parameter. JSON-shaped so - // arrays/objects/null/primitives all round-trip. Validated against the - // question's parameters[] at save time in the service layer. - args: jsonb('args').$type().notNull().default([]), - expectedReturn: jsonb('expected_return').$type().notNull().default(null), - isHidden: boolean('is_hidden').notNull().default(false), - position: integer('position').notNull().default(0), -}); +export const questionLibraryTestCaseTable = pgTable( + 'question_library_test_cases', + { + id: text('id').primaryKey().$type>(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + questionId: text('question_id') + .notNull() + .references(() => questionLibraryTable.id, { onDelete: 'cascade' }), + label: text('label').notNull().default(''), + // Positional arg values, one entry per question parameter. JSON-shaped so + // arrays/objects/null/primitives all round-trip. Validated against the + // question's parameters[] at save time in the service layer. + args: jsonb('args').$type().notNull().default([]), + expectedReturn: jsonb('expected_return').$type().notNull().default(null), + isHidden: boolean('is_hidden').notNull().default(false), + position: integer('position').notNull().default(0), + }, + (t) => [index('idx_test_case_question').on(t.questionId)] +); export type QuestionLibraryTestCaseEntity = typeof questionLibraryTestCaseTable.$inferSelect; diff --git a/packages/db/src/questionSubmission.db.ts b/packages/db/src/questionSubmission.db.ts index 7c6987c..0eefd1b 100644 --- a/packages/db/src/questionSubmission.db.ts +++ b/packages/db/src/questionSubmission.db.ts @@ -1,33 +1,37 @@ import type { Id } from '@coderscreen/common/id'; import { sql } from 'drizzle-orm'; -import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { assessmentQuestionTable } from './assessmentQuestion.db'; import { assessmentSubmissionTable } from './assessmentSubmission.db'; import { organization } from './user.db'; -export const questionSubmissionTable = pgTable('question_submissions', { - id: text('id').primaryKey().$type>(), - createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - submissionId: text('submission_id') - .notNull() - .references(() => assessmentSubmissionTable.id, { onDelete: 'cascade' }), - questionId: text('question_id') - .notNull() - .references(() => assessmentQuestionTable.id, { onDelete: 'cascade' }), - organizationId: text('organization_id') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - code: text('code').notNull().default(''), - language: text('language'), - timeSpentSeconds: integer('time_spent_seconds').notNull().default(0), - score: integer('score'), - maxScore: integer('max_score'), - isDraft: boolean('is_draft').notNull().default(true), -}); +export const questionSubmissionTable = pgTable( + 'question_submissions', + { + id: text('id').primaryKey().$type>(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + updatedAt: timestamp('updated_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + submissionId: text('submission_id') + .notNull() + .references(() => assessmentSubmissionTable.id, { onDelete: 'cascade' }), + questionId: text('question_id') + .notNull() + .references(() => assessmentQuestionTable.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + code: text('code').notNull().default(''), + language: text('language'), + timeSpentSeconds: integer('time_spent_seconds').notNull().default(0), + score: integer('score'), + maxScore: integer('max_score'), + isDraft: boolean('is_draft').notNull().default(true), + }, + (t) => [index('idx_question_submission_submission_question').on(t.submissionId, t.questionId)] +); export type QuestionSubmissionEntity = typeof questionSubmissionTable.$inferSelect; diff --git a/packages/db/src/testCaseResult.db.ts b/packages/db/src/testCaseResult.db.ts index 77b57ae..d013ff0 100644 --- a/packages/db/src/testCaseResult.db.ts +++ b/packages/db/src/testCaseResult.db.ts @@ -1,32 +1,39 @@ import type { Id } from '@coderscreen/common/id'; import { sql } from 'drizzle-orm'; -import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, index, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { questionLibraryTestCaseTable } from './questionLibraryTestCase.db'; import { questionSubmissionTable } from './questionSubmission.db'; import { organization } from './user.db'; export type TestCaseFailureReason = 'passed' | 'compile' | 'timeout' | 'crash' | 'wrong_output'; -export const testCaseResultTable = pgTable('test_case_results', { - id: text('id').primaryKey().$type>(), - createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) - .default(sql`now()`) - .notNull(), - questionSubmissionId: text('question_submission_id') - .notNull() - .references(() => questionSubmissionTable.id, { onDelete: 'cascade' }), - testCaseId: text('test_case_id') - .notNull() - .references(() => questionLibraryTestCaseTable.id, { onDelete: 'cascade' }), - organizationId: text('organization_id') - .notNull() - .references(() => organization.id, { onDelete: 'cascade' }), - passed: boolean('passed').notNull(), - failureReason: text('failure_reason').$type().notNull().default('passed'), - actualOutput: text('actual_output').notNull().default(''), - stderr: text('stderr').notNull().default(''), - exitCode: integer('exit_code').notNull().default(0), - executionTimeMs: integer('execution_time_ms'), -}); +export const testCaseResultTable = pgTable( + 'test_case_results', + { + id: text('id').primaryKey().$type>(), + createdAt: timestamp('created_at', { mode: 'string', withTimezone: true }) + .default(sql`now()`) + .notNull(), + questionSubmissionId: text('question_submission_id') + .notNull() + .references(() => questionSubmissionTable.id, { onDelete: 'cascade' }), + testCaseId: text('test_case_id') + .notNull() + .references(() => questionLibraryTestCaseTable.id, { onDelete: 'cascade' }), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + passed: boolean('passed').notNull(), + failureReason: text('failure_reason') + .$type() + .notNull() + .default('passed'), + actualOutput: text('actual_output').notNull().default(''), + stderr: text('stderr').notNull().default(''), + exitCode: integer('exit_code').notNull().default(0), + executionTimeMs: integer('execution_time_ms'), + }, + (t) => [index('idx_test_case_result_question_submission').on(t.questionSubmissionId)] +); export type TestCaseResultEntity = typeof testCaseResultTable.$inferSelect; From 2cbf21781b284c911606bdb3ffe183d1eac2421f Mon Sep 17 00:00:00 2001 From: rogutkuba Date: Sat, 25 Jul 2026 18:45:13 -0400 Subject: [PATCH 2/2] fix: type test-case cache patches to fix web build The Hono RPC client infers the PATCH test-case response as `never`, so reading `.id` off it broke `tsc` in the full build (where the api types resolve). Pin the fields the cache operations read via a CachedTestCase type. Isolated web tsc missed this because it degrades RPC types to any. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/query/assessment.query.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/query/assessment.query.ts b/apps/web/src/query/assessment.query.ts index e744107..b22e3d9 100644 --- a/apps/web/src/query/assessment.query.ts +++ b/apps/web/src/query/assessment.query.ts @@ -20,6 +20,11 @@ import { apiClient } from './client'; // (all questions + all test cases) on every keystroke-level save. // biome-ignore lint/suspicious/noExplicitAny: cache payloads are loosely typed here (see useAssessment) type CachedQuestion = any; +// Minimal shape of a test case as we manipulate it in the cache. The RPC client +// infers these mutation responses imprecisely (the resolved type collapses to +// `never` for the PATCH endpoint), so we pin the fields the cache operations +// actually read. +type CachedTestCase = { id: string; position: number }; const patchAssessmentQuestions = ( queryClient: QueryClient, assessmentId: string, @@ -446,7 +451,7 @@ export const useCreateTestCase = (assessmentId: string, questionId: string) => { if (!response.ok) { await throwApiError(response); } - return response.json(); + return (await response.json()) as unknown as CachedTestCase; }, onSuccess: (newTestCase) => { patchAssessmentQuestions(queryClient, assessmentId, (questions) => @@ -455,7 +460,7 @@ export const useCreateTestCase = (assessmentId: string, questionId: string) => { ? { ...q, testCases: [...(q.testCases ?? []), newTestCase].sort( - (a, b) => a.position - b.position + (a: CachedTestCase, b: CachedTestCase) => a.position - b.position ), } : q @@ -495,7 +500,7 @@ export const useUpdateTestCase = (assessmentId: string, questionId: string) => { if (!response.ok) { await throwApiError(response); } - return response.json(); + return (await response.json()) as unknown as CachedTestCase; }, onSuccess: (updatedTestCase) => { patchAssessmentQuestions(queryClient, assessmentId, (questions) => @@ -504,12 +509,10 @@ export const useUpdateTestCase = (assessmentId: string, questionId: string) => { ? { ...q, testCases: (q.testCases ?? []) - .map((tc: { id: string }) => + .map((tc: CachedTestCase) => tc.id === updatedTestCase.id ? updatedTestCase : tc ) - .sort( - (a: { position: number }, b: { position: number }) => a.position - b.position - ), + .sort((a: CachedTestCase, b: CachedTestCase) => a.position - b.position), } : q )