diff --git a/docker/docker-compose.prod.yaml b/docker/docker-compose.prod.yaml index b00131f6..077fa2b1 100644 --- a/docker/docker-compose.prod.yaml +++ b/docker/docker-compose.prod.yaml @@ -10,11 +10,11 @@ services: - VITE_API_URL=${BACKEND_URI:-http://localhost:3123} - VITE_APP_URL=${FRONTEND_URI:-http://localhost:5173} - VITE_INACTIVITY_LIMIT=1 - - VITE_DEBUG_RECAPP=1 # change to 0 for production + - VITE_DEBUG_RECAPP=0 # change to 1 for diagnostic builds environment: - FRONTEND_URI=${FRONTEND_URI} - BACKEND_URI=${BACKEND_URI} - - VITE_DEBUG_RECAPP=1 # change to 0 for production + - VITE_DEBUG_RECAPP=0 # change to 1 for diagnostic builds ports: - "5173:80" depends_on: diff --git a/packages/backend/src/actors/QuizActor.ts b/packages/backend/src/actors/QuizActor.ts index 3ede3f44..543ef3d9 100644 --- a/packages/backend/src/actors/QuizActor.ts +++ b/packages/backend/src/actors/QuizActor.ts @@ -284,10 +284,12 @@ export class QuizActor extends SubscribableActor { const db = await this.connector.db(); - const mbRun = maybe(await db.collection("quizruns").findOne({ studentId, quizId })); + const found = await db.collection("quizruns").findOne({ studentId, quizId }); + const mbRun = maybe(found); this.logger.debug( `GETUSERRUN studentId=${String(studentId)} quizId=${String(quizId)} ` + - `runPresent=${mbRun ? "maybe" : "none"}` + `runPresent=${found ? "yes" : "no"} ` + + `runUid=${found?.uid ?? "-"} counter=${found?.counter ?? "-"}` ); return mbRun.match(identity, () => new Error("No run for user")); }, diff --git a/packages/backend/src/actors/QuizRunActor.ts b/packages/backend/src/actors/QuizRunActor.ts index 8d9cb6ee..38631fb0 100644 --- a/packages/backend/src/actors/QuizRunActor.ts +++ b/packages/backend/src/actors/QuizRunActor.ts @@ -15,7 +15,6 @@ import { create } from "mutative"; import { identity, pick } from "rambda"; import { logger } from "../logger"; import { v4 } from "uuid"; -import { maybe } from "tsmonads"; type State = { cache: Map; @@ -53,6 +52,25 @@ export class QuizRunActor extends SubscribableActor { + try { + const db = await this.connector.db(); + await db + .collection(this.collectionName) + .createIndex({ studentId: 1, quizId: 1 }, { unique: true, name: "studentId_quizId_unique" }); + } catch (e) { + // Pre-existing duplicate (studentId, quizId) docs from the prior race condition + // will block this index creation. Continue without the index — the atomic upsert + // is still narrower than the previous read-then-create. Deduplicate and re-deploy + // to gain the strict guarantee. + this.logger.warn( + `QUIZRUNACTOR could not create unique index on (studentId, quizId): ${ + e instanceof Error ? e.message : String(e) + }` + ); + } + } + public async receive(from: ActorRef, message: QuizRunActorMessage): Promise { const [clientUserRole, clientUserId] = await this.determineRole(from); if (typeof message === "string" && message === "SHUTDOWN") { @@ -67,48 +85,52 @@ export class QuizRunActor extends SubscribableActor>(message, { GetForUser: async ({ studentId, questions }) => { + if (questions.length === 0) return undefined as any; const db = await this.connector.db(); - const mbRunId = maybe( - await db - .collection(this.collectionName) - .findOne({ studentId, quizId: this.uid }, { uid: 1, _id: 0 } as any) - ); - const result = mbRunId.match>( - async runId => { - const run = await this.getEntity(runId.uid); - // console.log("Found existing run", run); - this.logger.debug(`QUIZRUNACTOR found existing run present=${run ? "maybe" : "none"}`); - return run.match(identity, () => new Error()); - }, - async () => { - if (questions.length === 0) return undefined as any; - const run: QuizRun = { - uid: v4() as Id, - studentId, - quizId: this.uid, - counter: 0, - questions, - answers: [], - created: toTimestamp(), - updated: toTimestamp(), - correct: [], - wrong: [], - }; - await this.storeEntity(run); - for (const [subscriber, subscription] of this.state.collectionSubscribers) { - this.send( - subscriber, - new QuizRunUpdateMessage( - subscription.properties.length > 0 ? pick(subscription.properties, run) : run - ) - ); - } - // console.log("Created new run", run); - this.logger.info(`QUIZRUNACTOR created new run`); - return run; + const candidate: QuizRun = { + uid: v4() as Id, + studentId, + quizId: this.uid, + counter: 0, + questions, + answers: [], + created: toTimestamp(), + updated: toTimestamp(), + correct: [], + wrong: [], + }; + // Atomic upsert. Replaces the previous findOne + conditional insert, + // which allowed concurrent GetForUser calls to each create sibling + // run documents (Question Reset / Repetition glitch root cause). + const stored = (await db + .collection(this.collectionName) + .findOneAndUpdate( + { studentId, quizId: this.uid }, + { $setOnInsert: candidate }, + { upsert: true, returnDocument: "after" } + )) as unknown as QuizRun | null; + if (!stored) { + return new Error("Failed to upsert quiz run"); + } + if (stored.uid === candidate.uid) { + // We just inserted — notify collection subscribers using the + // in-memory candidate object (avoids leaking MongoDB's _id field). + for (const [subscriber, subscription] of this.state.collectionSubscribers) { + this.send( + subscriber, + new QuizRunUpdateMessage( + subscription.properties.length > 0 + ? pick(subscription.properties, candidate) + : candidate + ) + ); } - ); - return result; + this.logger.info(`QUIZRUNACTOR created new run`); + return candidate; + } + const existing = await this.getEntity(stored.uid); + this.logger.debug(`QUIZRUNACTOR returning existing run`); + return existing.match(identity, () => new Error()); }, Update: async run => { const existingRun = await this.getEntity(run.uid); @@ -140,7 +162,16 @@ export class QuizRunActor extends SubscribableActor { const db = await this.connector.db(); const result = await db.collection(this.collectionName).deleteMany({ quizId: this.uid }); - logger.warn(JSON.stringify(result)); + const runSubscriberCount = Array.from(this.state.subscribers.values()) + .reduce((acc, set) => acc + set.size, 0); + const collectionSubscriberCount = this.state.collectionSubscribers.size; + logger.warn( + `QUIZRUNACTOR_CLEAR quizId=${String(this.uid)} ` + + `deletedCount=${result.deletedCount} ` + + `runSubscribers=${runSubscriberCount} ` + + `collectionSubscribers=${collectionSubscriberCount} ` + + `from=${String((from as any)?.name ?? from)}` + ); this.state.cache = new Map(); this.state.subscribers.forEach(subscriberSet => subscriberSet.forEach(subscriber => this.send(subscriber, new QuizRunDeletedMessage())) diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index e47aafc2..c45cb274 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -31,7 +31,10 @@ export const bearerValid = async (idTokenString: string): Promise => { .orUndefined(); try { if (userId) { - await system.ask(createActorUri("SessionStore"), SessionStoreMessages.GetSessionForUserId(userId)); + const result = await system.ask(createActorUri("SessionStore"), SessionStoreMessages.GetSessionForUserId(userId)); + if (result instanceof Error) { + return Promise.reject(new Error("Session expired")); + } return Promise.resolve(userId); } else { return Promise.reject(new Error("Unknown user")); diff --git a/packages/frontend/Dockerfile b/packages/frontend/Dockerfile index 56f45ff6..a587f820 100644 --- a/packages/frontend/Dockerfile +++ b/packages/frontend/Dockerfile @@ -13,6 +13,22 @@ COPY packages/models/tsconfig*.json ./packages/models/ RUN npm ci +# Surface actor-crash context. ts-actors swallows the underlying Error in +# its supervisor warning; this rewrites the catch block to include the +# full stack inline. Kept on for production so backend log inspection +# stays useful. Browser-side noise stays minimal — only the warn line is +# affected; no extra console.error per crash. +RUN node -e " \ + const fs = require('fs'); \ + const p = '/app/node_modules/ts-actors/lib/src/ActorSystem.js'; \ + const s = fs.readFileSync(p, 'utf8'); \ + const oldLine = 'this.logger.warn(\`Unhandled exception in \${target.name}, applying strategy \${target.strategy}\`);'; \ + const newLine = 'this.logger.warn(\`Unhandled exception in \${target.name}, applying strategy \${target.strategy}: \${(e && e.stack) ? e.stack : String(e)}\`);'; \ + if (!s.includes(oldLine)) { console.error('PATCH FAILED: target line not found in', p); process.exit(1); } \ + fs.writeFileSync(p, s.replace(oldLine, newLine)); \ + console.log('Patched ts-actors ActorSystem.js to surface error stacks'); \ +" + COPY packages/frontend ./packages/frontend COPY packages/models ./packages/models diff --git a/packages/frontend/src/actors/CurrentQuizActor.ts b/packages/frontend/src/actors/CurrentQuizActor.ts index 45dea603..f17546ba 100644 --- a/packages/frontend/src/actors/CurrentQuizActor.ts +++ b/packages/frontend/src/actors/CurrentQuizActor.ts @@ -110,9 +110,17 @@ export type CurrentQuizState = { runReady: boolean; hasInitialQuestions: boolean; questionsSubscribed: boolean; + runFetchStarted: boolean; }; export class CurrentQuizActor extends StatefulActor { + // Override the ts-actors default ("Shutdown"). A long-lived session actor + // shouldn't die from one handler exception (e.g. a timed-out ask raising + // the string-rejection contract from DistributedActorSystem.js:41). The + // supervisor still logs the warning + console.error; we just keep + // processing the next message instead of freezing the page. + strategy = "Resume" as const; + private quiz: Maybe = nothing(); private user: Maybe = nothing(); private firstListReported = false; // for debugging: emit a single LIST_RESULT when the list goes from 0 → N for the first time. @@ -135,6 +143,7 @@ export class CurrentQuizActor extends StatefulActor { + const incomingCounter = (message.run as Partial).counter; + const beforeCounter = draft.run?.counter ?? null; + const currentCounter = draft.run?.counter ?? 0; + if ( + draft.run && + typeof incomingCounter === "number" && + incomingCounter < currentCounter + ) { + // Defence-in-depth: a WS update carrying a counter lower than the + // current (optimistic) state would regress run.counter and trigger + // RunningQuizTab's useEffect, resetting answered/answers and either + // re-enabling the previous question (repetition) or hiding the Next + // button (reset). Ignore it. + d.runState({ + source: "QuizRunUpdate", + beforeCounter, + afterCounter: beforeCounter, + runUidBefore: draft.run?.uid, + blocked: true, + reason: "stale-counter", + }); + return; + } draft.run = { ...draft.run, ...message.run } as QuizRun; draft.result = { ...draft.result, ...message.run } as QuizRun; + d.runState({ + source: "QuizRunUpdate", + beforeCounter, + afterCounter: draft.run?.counter ?? null, + runUidBefore: draft.run?.uid, + runUidAfter: draft.run?.uid, + }); }); return nothing(); } else if (message.tag === "QuizRunDeletedMessage") { this.updateState(draft => { + d.runState({ + source: "QuizRunDeleted", + beforeCounter: draft.run?.counter ?? null, + afterCounter: null, + runUidBefore: draft.run?.uid, + }); draft.run = undefined; + draft.runFetchStarted = false; }); return nothing(); } else if (message.tag === "StatisticsUpdateMessage") { @@ -317,6 +363,12 @@ export class CurrentQuizActor extends StatefulActor>(m, { Reset: async () => { this.updateState(draft => { + d.runState({ + source: "Reset", + beforeCounter: draft.run?.counter ?? null, + afterCounter: null, + runUidBefore: draft.run?.uid, + }); draft.quiz = {} as Quiz; draft.comments = []; draft.questions = []; @@ -331,28 +383,84 @@ export class CurrentQuizActor extends StatefulActor { + // Synchronous dedup. ts-actors does not serialize handler + // invocations per actor (Actor.send dispatches via setTimeout + + // RxJS Subject), so multiple GetRun messages queued by + // SetQuiz + handleRemoteUpdates would otherwise all start + // concurrent asks. The flag is set before the first await, so + // JS run-to-completion guarantees later invocations see it. + if (this.state.runFetchStarted) { + return (this.state.run ?? (undefined as unknown)) as QuizRun; + } + this.updateState(s => { s.runFetchStarted = true; }); + const studentId: Id = this.user.map(u => u.uid).orElse(toId("")); const quizId: Id = this.quiz.orElse(toId("")); // structured RUN start d.run({ quizId, studentIdHash: anonUserKey(String(studentId), String(quizId)), action: "start" }); - // build question ids from quiz metadata (groups → questions) - const questionIds: Id[] = (this.state.quiz?.groups ?? []) - .reduce((acc, g) => [...acc, ...(g.questions ?? [])], [] as Id[]); + // Build question IDs from quiz metadata. Filter by approved when Question + // objects are already in the WS cache; if not yet loaded, include the + // question (safe default — GetForUser is get-or-create, so an existing + // run is returned unchanged and the questions param is ignored). + let questionIds: Id[] = (this.state.quiz?.groups ?? []) + .reduce((acc, g) => [...acc, ...(g.questions ?? [])], [] as Id[]) + .filter(q => { + const question = this.state.questions.find(qu => qu.uid === q); + return !question || question.approved; + }); + if (this.state.quiz.shuffleQuestions) { + questionIds = shuffle(Math.random)(questionIds); + } - // IMPORTANT: quiz-scoped run actor (prefix + quizId), and use GetForUser (get-or-create) - const run = await this.ask( - `${actorUris.QuizRunActorPrefix}${quizId}`, - QuizRunActorMessages.GetForUser({ studentId, questions: questionIds }) - ); + let run: QuizRun; + try { + // IMPORTANT: quiz-scoped run actor (prefix + quizId), and use GetForUser (get-or-create) + run = (await this.ask( + `${actorUris.QuizRunActorPrefix}${quizId}`, + QuizRunActorMessages.GetForUser({ studentId, questions: questionIds }) + )) as QuizRun; + } catch (e) { + // Allow a future retry by clearing the in-flight flag. + this.updateState(s => { s.runFetchStarted = false; }); + throw e; + } d.run({ quizId, studentIdHash: anonUserKey(String(studentId), String(quizId)), action: "ok" }); - this.updateState(s => { s.run = run as QuizRun; s.runReady = true; }); + this.updateState(s => { + // Counter guard. The backend atomic upsert removes the + // sibling-run race, but a late completion that races with + // LogAnswer's optimistic counter increment must not regress + // the visible state. + const beforeCounter = s.run?.counter ?? null; + const runUidBefore = s.run?.uid; + if (!s.run || (run?.counter ?? 0) >= (s.run.counter ?? 0)) { + s.run = run; + d.runState({ + source: "GetRun", + beforeCounter, + afterCounter: s.run?.counter ?? null, + runUidBefore, + runUidAfter: s.run?.uid, + }); + } else { + d.runState({ + source: "GetRun", + beforeCounter, + afterCounter: beforeCounter, + runUidBefore, + blocked: true, + reason: "stale-counter", + }); + } + s.runReady = true; + }); // Subscribe & fetch questions exactly once, AFTER run is ready if (!this.state.questionsSubscribed) { @@ -362,7 +470,7 @@ export class CurrentQuizActor extends StatefulActor { const quiz: Quiz = await this.ask(actorUris.QuizActor, QuizActorMessages.Get(quizId)); @@ -395,8 +503,30 @@ export class CurrentQuizActor extends StatefulActor { - draft.run = run; - draft.result = run; + const beforeCounter = draft.run?.counter ?? null; + const runUidBefore = draft.run?.uid; + // Same guard as GetRun: a delayed StartQuiz completion + // must not regress an already-advanced optimistic state. + if (!draft.run || (run?.counter ?? 0) >= (draft.run.counter ?? 0)) { + draft.run = run; + draft.result = run; + d.runState({ + source: "StartQuiz", + beforeCounter, + afterCounter: draft.run?.counter ?? null, + runUidBefore, + runUidAfter: draft.run?.uid, + }); + } else { + d.runState({ + source: "StartQuiz", + beforeCounter, + afterCounter: beforeCounter, + runUidBefore, + blocked: true, + reason: "stale-counter", + }); + } }); return unit(); @@ -436,12 +566,31 @@ export class CurrentQuizActor extends StatefulActor { + if (draft.run) { + const beforeCounter = draft.run.counter; + draft.run.counter = nextCounter; + draft.run.answers = answers as QuizRun["answers"]; + draft.run.correct = correct; + draft.run.wrong = wrong; + d.runState({ + source: "LogAnswer", + beforeCounter, + afterCounter: nextCounter, + runUidBefore: draft.run.uid, + runUidAfter: draft.run.uid, + }); + } + }); + this.send( `${actorUris.QuizRunActorPrefix}${this.quiz.orElse(toId("-"))}`, QuizRunActorMessages.Update({ uid: this.state.run.uid, answers, - counter: this.state.run.counter + 1, + counter: nextCounter, correct, wrong, }) @@ -728,7 +877,33 @@ export class CurrentQuizActor extends StatefulActor 0) { this.updateState(draft => { - draft.run = run as QuizRun; + // Counter guard parallels GetRun. Without this, a + // late-returning GetUserRun whose findOne saw the + // DB before LogAnswer's Update committed can + // regress state.run.counter against an optimistic + // state already populated by a parallel GetRun. + const incoming = run as QuizRun; + const beforeCounter = draft.run?.counter ?? null; + const runUidBefore = draft.run?.uid; + if (!draft.run || (incoming.counter ?? 0) >= (draft.run.counter ?? 0)) { + draft.run = incoming; + d.runState({ + source: "SetQuiz-same", + beforeCounter, + afterCounter: draft.run?.counter ?? null, + runUidBefore, + runUidAfter: draft.run?.uid, + }); + } else { + d.runState({ + source: "SetQuiz-same", + beforeCounter, + afterCounter: beforeCounter, + runUidBefore, + blocked: true, + reason: "stale-counter", + }); + } }); } else { this.send(this.ref, CurrentQuizMessages.StartQuiz()); @@ -759,6 +934,12 @@ export class CurrentQuizActor extends StatefulActor { + d.runState({ + source: "SetQuiz-different", + beforeCounter: draft.run?.counter ?? null, + afterCounter: null, + runUidBefore: draft.run?.uid, + }); draft.run = undefined; draft.result = undefined; draft.questionStats = undefined; @@ -766,6 +947,9 @@ export class CurrentQuizActor extends StatefulActor u.uid).orElse(toId("")); const quizId: Id = this.quiz.orElse(toId("")); @@ -847,10 +1031,28 @@ export class CurrentQuizActor extends StatefulActor { - const names: Array<{ nickname?: string; username: string }> = await this.ask( - actorUris.UserStore, - UserStoreMessages.GetNames(this.state.quiz.teachers) - ); + // DistributedActorSystem rejects the ask Promise with a string on + // timeout; without the try/catch the rejection unwinds out of the + // handler and the supervisor shuts CurrentQuiz down (see + // getuserrun-counter-regression investigation). Names are cosmetic; + // degrade to empty rather than killing the session. + let names: Array<{ nickname?: string; username: string }> = []; + try { + const result = await this.ask( + actorUris.UserStore, + UserStoreMessages.GetNames(this.state.quiz.teachers) + ); + if (Array.isArray(result)) { + names = result; + } + } catch (e) { + d.runState({ + source: "AskFailure", + beforeCounter: null, + afterCounter: null, + reason: `GetTeacherNames ask failed: ${String(e)}`, + }); + } this.updateState(draft => { draft.teacherNames = names.map(n => n.nickname ? `${n.username} (${n.nickname})` : n.username diff --git a/packages/frontend/src/actors/LocalUserActor.ts b/packages/frontend/src/actors/LocalUserActor.ts index cf673477..ad337a57 100644 --- a/packages/frontend/src/actors/LocalUserActor.ts +++ b/packages/frontend/src/actors/LocalUserActor.ts @@ -60,6 +60,13 @@ export type LocalUserState = { }; export class LocalUserActor extends StatefulActor { + // Override the ts-actors default ("Shutdown"). A long-lived session actor + // shouldn't die from one handler exception (e.g. a timed-out ask raising + // the string-rejection contract from DistributedActorSystem.js:41). The + // supervisor still logs the warning + console.error; we just keep + // processing the next message instead of freezing the page. + strategy = "Resume" as const; + constructor(name: string, system: ActorSystem) { super(name, system); this.state = { @@ -115,9 +122,21 @@ export class LocalUserActor extends StatefulActor { if (message.quiz.uid) { const isTeacher = message.quiz.teachers?.includes(this.state.user?.uid ?? toId("")); diff --git a/packages/frontend/src/components/quiz-tabs/RunningQuizTab.tsx b/packages/frontend/src/components/quiz-tabs/RunningQuizTab.tsx index 35d2edaa..9960f74b 100644 --- a/packages/frontend/src/components/quiz-tabs/RunningQuizTab.tsx +++ b/packages/frontend/src/components/quiz-tabs/RunningQuizTab.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { i18n } from "@lingui/core"; import MDEditor, { commands } from "@uiw/react-md-editor"; import "katex/dist/katex.css"; @@ -18,6 +18,7 @@ import { isMultiChoiceAnsweredCorrectly } from "../../utils"; import { Trans } from "@lingui/react"; import { CHECK_SYMBOL, X_SYMBOL } from "../../constants/layout"; import { CORRECT_COLOR, WRONG_COLOR, CORRECT_COLOR_TEXT, WRONG_COLOR_TEXT } from "../../colorPalette"; +import { d } from "../../utils/debugLog"; export const RunningQuizTab: React.FC<{ isUserInTeachersList:boolean; @@ -30,16 +31,19 @@ export const RunningQuizTab: React.FC<{ const [textAnswer, setTextAnswer] = useState(""); const [answers, setAnswers] = useState([]); const { run, questions: qData } = quizState; - console.log( - "QUES", - quizState.questions.length, - "RUN", - quizState.run, - "ENTRY", - quizState.run?.counter, - "FOO", - quizState.questions[0] - ); + + useEffect(() => { + d.runState({ + source: "useEffect-counter", + beforeCounter: null, + afterCounter: run?.counter ?? null, + runUidAfter: run?.uid, + reason: "counter-dep-fired", + }); + setAnswered(false); + setTextAnswer(""); + setAnswers([]); + }, [run?.counter]); const questions = run?.questions.map(id => qData.find(q => q.uid === id)) ?? []; const currentQuestion = questions[run?.counter ?? 0]; @@ -47,8 +51,6 @@ export const RunningQuizTab: React.FC<{ const questionText = questions.at(run?.counter ?? 0)?.text; const { rendered, isStale } = useRendered({ value: questionText ?? "" }); - console.log("ANSWERSTATE", quizState, run); - if (!quizState.run || !quizState.questions) { return null; } @@ -71,10 +73,6 @@ export const RunningQuizTab: React.FC<{ const nextQuestion = () => { logQuestionClicked(); - - setAnswered(false); - setTextAnswer(""); - setAnswers([]); }; const updateAnswer = (index: number, value: boolean) => { @@ -86,12 +84,10 @@ export const RunningQuizTab: React.FC<{ a[i] = false; } a[index] = value; - console.log("ANSWERS NEW", a, value); setAnswers(a); } else { const a = answersCopy; a[index] = value; - console.log("ANSWERS", a, value); setAnswers(a); } }; diff --git a/packages/frontend/src/pages/QuestionEdit.tsx b/packages/frontend/src/pages/QuestionEdit.tsx index da82dc63..a1439c59 100644 --- a/packages/frontend/src/pages/QuestionEdit.tsx +++ b/packages/frontend/src/pages/QuestionEdit.tsx @@ -76,6 +76,7 @@ export const QuestionEdit: React.FC = () => { runReady: false, hasInitialQuestions: false, questionsSubscribed: false, + runFetchStarted: false, }); const q = mbQuiz.map(q => q.quiz).orUndefined(); diff --git a/packages/frontend/src/utils/debugLog.ts b/packages/frontend/src/utils/debugLog.ts index 629c6337..3a04c002 100644 --- a/packages/frontend/src/utils/debugLog.ts +++ b/packages/frontend/src/utils/debugLog.ts @@ -1,6 +1,7 @@ type LogTag = | "AUTH" | "RUN" + | "RUN_STATE_WRITE" | "LIST_REQUEST" | "LIST_RESULT" | "WS_OPEN" @@ -23,6 +24,29 @@ type RunLog = BaseLog & { error?: string; }; +// Tracks every write to CurrentQuizActor's state.run (or attempted write +// blocked by a counter guard). Use to diagnose counter regressions and +// to verify which code path produced any given state change. +type RunStateWriteLog = BaseLog & { + source: + | "GetRun" + | "StartQuiz" + | "SetQuiz-same" + | "SetQuiz-different" + | "LogAnswer" + | "QuizRunUpdate" + | "QuizRunDeleted" + | "Reset" + | "useEffect-counter" + | "AskFailure"; + beforeCounter: number | null; // null = state.run was undefined + afterCounter: number | null; // null = state.run set/left as undefined + runUidBefore?: string; + runUidAfter?: string; + blocked?: boolean; // true = guard rejected the write + reason?: string; +}; + type ListRequestLog = BaseLog & { transport: "http" | "actor"; urlOrMsg?: string; @@ -72,6 +96,7 @@ export function dlog( export const d = { auth: (p: Omit) => dlog("AUTH", p), run: (p: Omit) => dlog("RUN", p), + runState: (p: Omit) => dlog("RUN_STATE_WRITE", p), listReq: (p: Omit) => dlog("LIST_REQUEST", p), listRes: (p: Omit) => dlog("LIST_RESULT", p), wsLife: (p: Omit) => dlog("WS_OPEN", p),