import type { QuizAttemptPhase, QuizAttemptSkeleton, RuntimeRecord, RuntimeSession, } from '@openmaic/dsl'; import { RuntimeAppendConflictError, type RuntimeStore } from '@openmaic/storage'; import type { QuestionResult } from '@/lib/quiz/grading'; import { clearDraftRecovery, clearLegacyQuizStateSnapshot, readLegacyQuizStateSnapshot, type QuizAnswers, } from '@/lib/quiz/persistence'; import { getLearnerKey } from '@/lib/runtime/learner-key'; import { getRuntimeStore } from '@/lib/runtime/store'; export interface QuizAttemptPayload extends QuizAttemptSkeleton { payloadVersion: 1; phase: QuizAttemptPhase; answers: QuizAnswers; results?: QuestionResult[]; } export interface QuizAttemptRecordInput { stageId: string; sceneId: string; attemptId: string; phase: QuizAttemptPhase; answers: QuizAnswers; results?: QuestionResult[]; /** Begin a distinct retry even when the prior attempt has the same payload. */ startNewAttempt?: boolean; } export interface LegacyQuizAttemptInput { stageId: string; sceneId: string; attemptId: string; draftAnswers?: QuizAnswers; submittedAnswers?: QuizAnswers; results?: QuestionResult[]; } export interface QuizAttemptRuntimeDeps { store?: RuntimeStore; learnerKey?: string; now?: () => string; mintRecordId?: () => string; } export class QuizRetryProgressedError extends Error { constructor(sessionId: string) { super(`Quiz retry ${JSON.stringify(sessionId)} already progressed in another tab`); this.name = 'QuizRetryProgressedError'; } } export interface QuizAttemptState { sessionId: string; status: RuntimeSession['status']; phase: QuizAttemptPhase; answers: QuizAnswers; results?: QuestionResult[]; } export interface LoadedQuizAttemptState { /** Learner-scoped deterministic root id used by every new write/retry. */ attemptId: string; state?: QuizAttemptState; } export interface QuizAttemptStateInput { stageId: string; sceneId: string; } export type QuizDraftInput = Omit; export interface QuizAttemptWriter { scheduleDraft(input: QuizDraftInput): void; flushDraft(): Promise; recordPhase(input: QuizAttemptRecordInput): Promise; cancelDraft(): void; } export interface QuizAttemptWriterOptions { debounceMs?: number; write?: (input: QuizAttemptRecordInput) => Promise; onError?: (error: unknown) => void; } const PHASE_ORDER: Record = { draft: 0, submitted: 1, reviewed: 2, }; const queues = new WeakMap>>(); const writerTails = new Map>>(); async function awaitQueuedWriterLineage(attemptId: string): Promise { let queueKey = attemptId; while (true) { while (true) { const pending = writerTails.get(queueKey); if (!pending?.size) break; await Promise.all(pending); } const parent = queueKey.replace(/:retry:\d+$/, ''); if (parent === queueKey) return; queueKey = parent; } } async function awaitQueuedAttemptLineage(store: RuntimeStore, attemptId: string): Promise { let queueKey = attemptId; while (true) { const queued = queues.get(store)?.get(queueKey); if (queued) await queued; const parent = queueKey.replace(/:retry:\d+$/, ''); if (parent === queueKey) return; queueKey = parent; } } /** * Coalesce draft snapshots and serialize every phase through one local chain. * `recordPhase` synchronously queues a pending draft first, so submitted and * reviewed can never overtake the latest answers even though UI callers remain * fire-and-forget. */ export function createQuizAttemptWriter(options: QuizAttemptWriterOptions = {}): QuizAttemptWriter { const debounceMs = options.debounceMs ?? 500; const write = options.write ?? (async (input) => { await recordQuizAttempt(input); clearDraftRecovery(input.sceneId, input.attemptId, input.answers); }); const onError = options.onError ?? (() => {}); let pendingDraft: QuizDraftInput | undefined; let timer: ReturnType | undefined; let tail: Promise = Promise.resolve(); const clearTimer = () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; }; const run = (input: QuizAttemptRecordInput): Promise => { const operation = tail.then(() => write(input)); void operation.catch(onError); const settled = operation.catch(() => {}); tail = settled; let attemptTails = writerTails.get(input.attemptId); if (!attemptTails) { attemptTails = new Set(); writerTails.set(input.attemptId, attemptTails); } attemptTails.add(settled); void settled.finally(() => { attemptTails.delete(settled); if (attemptTails.size === 0 && writerTails.get(input.attemptId) === attemptTails) { writerTails.delete(input.attemptId); } }); return operation; }; const flushDraft = (): Promise => { clearTimer(); if (!pendingDraft) return tail; const input = pendingDraft; pendingDraft = undefined; return run({ ...input, phase: 'draft' }); }; return { scheduleDraft(input) { pendingDraft = input; clearTimer(); timer = setTimeout(() => { void flushDraft(); }, debounceMs); }, flushDraft, recordPhase(input) { void flushDraft(); return run(input); }, cancelDraft() { clearTimer(); pendingDraft = undefined; }, }; } function enqueue(store: RuntimeStore, attemptId: string, work: () => Promise): Promise { let storeQueues = queues.get(store); if (!storeQueues) { storeQueues = new Map(); queues.set(store, storeQueues); } const prior = storeQueues.get(attemptId) ?? Promise.resolve(); const current = prior.catch(() => {}).then(work); const settled = current.then( () => undefined, () => undefined, ); storeQueues.set(attemptId, settled); void settled.finally(() => { if (storeQueues.get(attemptId) === settled) storeQueues.delete(attemptId); }); return current; } async function withAttemptLock(attemptId: string, work: () => Promise): Promise { if (typeof navigator !== 'undefined' && navigator.locks) { return navigator.locks.request(`maic:quiz-attempt:${attemptId}`, work); } return work(); } function mintId(): string { const suffix = typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`; return `quiz-record:${suffix}`; } function asQuizPayload(record: RuntimeRecord | undefined): QuizAttemptPayload | undefined { if (!record || typeof record.payload !== 'object' || record.payload === null) return undefined; const payload = record.payload as Partial; if ( payload.payloadVersion !== 1 || (payload.phase !== 'draft' && payload.phase !== 'submitted' && payload.phase !== 'reviewed') || typeof payload.answers !== 'object' || payload.answers === null || Array.isArray(payload.answers) ) { return undefined; } return payload as QuizAttemptPayload; } function attemptIdSegment(value: string): string { return encodeURIComponent(value); } export function quizAttemptId(stageId: string, sceneId: string, learnerKey: string): string { return [ 'quiz-attempt', attemptIdSegment(stageId), attemptIdSegment(sceneId), attemptIdSegment(learnerKey), ].join(':'); } async function readLatestQuizAttemptState( input: QuizAttemptStateInput, store: RuntimeStore, learnerKey: string, ): Promise { const sessions = await store.listSessions(input.stageId, learnerKey); for (let index = sessions.length - 1; index >= 0; index -= 1) { const session = sessions[index]; if (session.kind !== 'quizAttempt') continue; const records = await store.listRecords(session.id, { sceneId: input.sceneId }); const payload = asQuizPayload(records.at(-1)); if (!payload) continue; return { sessionId: session.id, status: session.status, phase: payload.phase, answers: payload.answers, ...(payload.phase === 'reviewed' ? { results: Array.isArray(payload.results) ? payload.results : [] } : {}), }; } return undefined; } async function migrateLegacyQuizState( input: QuizAttemptStateInput, store: RuntimeStore, learnerKey: string, deps: QuizAttemptRuntimeDeps, ): Promise { const legacySnapshot = readLegacyQuizStateSnapshot(input.sceneId); if (!legacySnapshot.hasState) return; const existing = await readLatestQuizAttemptState(input, store, learnerKey); const { submitted, draft, attemptId: legacyAttemptId } = legacySnapshot; const legacyPhase: QuizAttemptPhase | undefined = submitted?.kind === 'reviewing' ? 'reviewed' : submitted?.kind === 'answering' ? 'submitted' : draft ? 'draft' : undefined; const legacyPointsToNewAttempt = legacyAttemptId !== null && (!existing || (existing.sessionId !== legacyAttemptId && !existing.sessionId.startsWith(`${legacyAttemptId}:retry:`))); const legacyPayloadMatchesExisting = existing !== undefined && legacyPhase === existing.phase && (legacyPhase === 'reviewed' && submitted?.kind === 'reviewing' ? sameAnswers(submitted.answers, existing.answers) && JSON.stringify(submitted.results) === JSON.stringify(existing.results ?? []) : legacyPhase === 'submitted' && submitted?.kind === 'answering' ? sameAnswers(submitted.answers, existing.answers) : legacyPhase === 'draft' && draft !== null ? sameAnswers(draft, existing.answers) : false); const shouldMigrate = legacyPointsToNewAttempt || (legacyPhase !== undefined && (!existing || PHASE_ORDER[legacyPhase] > PHASE_ORDER[existing.phase] || (PHASE_ORDER[legacyPhase] === PHASE_ORDER[existing.phase] && !legacyPayloadMatchesExisting))); if (shouldMigrate) { const attemptId = (legacyPointsToNewAttempt ? legacyAttemptId : existing?.sessionId) ?? quizAttemptId(input.stageId, input.sceneId, learnerKey); if (submitted?.kind === 'reviewing') { await backfillQuizAttempt( { ...input, attemptId, submittedAnswers: submitted.answers, results: submitted.results, }, { ...deps, store, learnerKey }, ); } else if (submitted?.kind === 'answering') { await backfillQuizAttempt( { ...input, attemptId, submittedAnswers: submitted.answers }, { ...deps, store, learnerKey }, ); } else if (draft) { await backfillQuizAttempt( { ...input, attemptId, draftAnswers: draft }, { ...deps, store, learnerKey }, ); } else if (legacyPointsToNewAttempt) { await recordQuizAttempt( { ...input, attemptId, phase: 'draft', answers: {} }, { ...deps, store, learnerKey }, ); } } // Legacy state is deleted only after every required runtime write succeeds. // Delete only the values this migration read; a newer recovery journal may // have arrived while its RuntimeStore writes were in flight. clearLegacyQuizStateSnapshot(input.sceneId, legacySnapshot); } /** Load the learner's latest quiz state, migrating legacy localStorage once. */ export async function loadQuizAttemptState( input: QuizAttemptStateInput, deps: QuizAttemptRuntimeDeps = {}, ): Promise { const store = deps.store ?? getRuntimeStore(); const learnerKey = deps.learnerKey ?? (await getLearnerKey()); const attemptId = quizAttemptId(input.stageId, input.sceneId, learnerKey); // A UI transition can expose the next consumer while its fire-and-forget // writer is still queued. Wait for both its private phase tail and the // RuntimeStore queue before opening a read. await awaitQueuedWriterLineage(attemptId); await awaitQueuedAttemptLineage(store, attemptId); await migrateLegacyQuizState(input, store, learnerKey, deps); let state = await withAttemptLock(attemptId, () => readLatestQuizAttemptState(input, store, learnerKey), ); if (state && state.sessionId !== attemptId) { // A shadow-written or rolled-over attempt can queue its next write under // this non-root session id, even after the session itself is completed. // Drain that lineage before choosing the authoritative latest attempt. await awaitQueuedWriterLineage(state.sessionId); await awaitQueuedAttemptLineage(store, state.sessionId); state = await withAttemptLock(rootAttemptId(state.sessionId), () => readLatestQuizAttemptState(input, store, learnerKey), ); } // Older shadow writers could append reviewed and crash before completing // the session. Replaying the same fact invokes the atomic tail-CAS repair. if (state?.phase === 'reviewed' && state.status === 'active') { await recordQuizAttempt( { ...input, attemptId: state.sessionId, phase: 'reviewed', answers: state.answers, results: state.results ?? [], }, { ...deps, store, learnerKey }, ); state = await readLatestQuizAttemptState(input, store, learnerKey); } return { attemptId: state?.status === 'active' ? state.sessionId : attemptId, state, }; } function samePayload(left: QuizAttemptPayload, right: QuizAttemptPayload): boolean { return JSON.stringify(left) === JSON.stringify(right); } function sameAnswers(left: QuizAnswers, right: QuizAnswers): boolean { return JSON.stringify(left) === JSON.stringify(right); } function rolloverAttemptId(attemptId: string, index: number): string { return `${attemptId}:retry:${index}`; } function rootAttemptId(attemptId: string): string { return attemptId.replace(/(?::retry:\d+)+$/, ''); } function compareSessionCreationOrder(left: RuntimeSession, right: RuntimeSession): number { return ( Date.parse(left.createdAt) - Date.parse(right.createdAt) || left.id.localeCompare(right.id) ); } function isInactiveSessionAppendError(error: unknown, sessionId: string): boolean { if (!(error instanceof Error)) return false; return error.message.includes( `cannot append to session ${JSON.stringify(sessionId)} with status`, ); } function assertPartition(session: RuntimeSession, stageId: string, learnerKey: string): void { if ( session.kind !== 'quizAttempt' || session.stageId !== stageId || session.learnerKey !== learnerKey ) { throw new Error( `Quiz attempt ${JSON.stringify(session.id)} does not belong to stage ` + `${JSON.stringify(stageId)} and learner ${JSON.stringify(learnerKey)}`, ); } } /** * Append one immutable quiz lifecycle fact. Calls for one attempt are serialized * so rapid draft writes cannot overtake submit or review writes. */ export async function recordQuizAttempt( input: QuizAttemptRecordInput, deps: QuizAttemptRuntimeDeps = {}, ): Promise { const store = deps.store ?? getRuntimeStore(); const learnerKey = deps.learnerKey ?? (await getLearnerKey()); const now = deps.now ?? (() => new Date().toISOString()); const mintRecordId = deps.mintRecordId ?? mintId; const rootId = rootAttemptId(input.attemptId); return enqueue(store, rootId, () => withAttemptLock(rootId, async () => { const timestamp = now(); const latestState = input.startNewAttempt ? await readLatestQuizAttemptState(input, store, learnerKey) : undefined; const authoritativeCompletedSession = latestState?.status === 'completed' ? await store.getSession(latestState.sessionId) : undefined; const payload: QuizAttemptPayload = { payloadVersion: 1, phase: input.phase, answers: input.answers, ...(input.results === undefined ? {} : { results: input.results }), }; let rolloverIndex = 0; let sessionId = input.attemptId; let originSession: RuntimeSession | undefined; while (true) { let session = await store.getSession(sessionId); let created = false; if (!session) { try { session = await store.createSession({ id: sessionId, kind: 'quizAttempt', stageId: input.stageId, learnerKey, status: 'active', createdAt: timestamp, updatedAt: timestamp, }); created = true; } catch (error) { // Without Web Locks, another tab may win the deterministic create // after our read. Re-read the winner instead of losing this write. session = await store.getSession(sessionId); if (!session) throw error; } } assertPartition(session, input.stageId, learnerKey); if (sessionId === input.attemptId) originSession = session; const records = await store.listRecords(sessionId); const foreignAnchor = records.find( (record) => record.sceneId !== undefined && record.sceneId !== input.sceneId, ); if (foreignAnchor) { throw new Error( `Quiz attempt ${JSON.stringify(sessionId)} is already anchored to scene ` + `${JSON.stringify(foreignAnchor.sceneId)}`, ); } // Canonical retry ids are scanned from one, but a stale caller may // already point at a later flat retry or a newer nested legacy retry. // Never move that caller backward onto an older active sibling. if ( !created && sessionId !== input.attemptId && originSession && compareSessionCreationOrder(session, originSession) <= 0 ) { rolloverIndex += 1; sessionId = rolloverAttemptId(rootId, rolloverIndex); continue; } const lastRecord = records.at(-1); const last = asQuizPayload(lastRecord); if (input.startNewAttempt && !created) { // A concurrent retry may already have created the first active child. // Reuse it instead of minting a second active branch whose newer // session ordering would hide writes that still resolve to this one. if (sessionId !== input.attemptId && session.status === 'active') { // A child with a durable fact already represents the retry. An // empty child can remain after create succeeds but append fails; // fall through so this call writes the missing draft marker. if (last?.phase === 'draft' && Object.keys(last.answers).length === 0) return; if ( last && authoritativeCompletedSession && authoritativeCompletedSession.id !== sessionId && compareSessionCreationOrder(session, authoritativeCompletedSession) < 0 ) { rolloverIndex += 1; sessionId = rolloverAttemptId(rootId, rolloverIndex); continue; } if (last) throw new QuizRetryProgressedError(sessionId); } else { rolloverIndex += 1; sessionId = rolloverAttemptId(rootId, rolloverIndex); continue; } } if (session.status === 'active') { if (last && PHASE_ORDER[payload.phase] < PHASE_ORDER[last.phase]) return; // An active session with a reviewed tail can exist from an older // client that appended before its separate completion write. Heal // only the status, guarded by the record tail in the same transaction. if (last && samePayload(last, payload) && payload.phase !== 'reviewed') return; if (last && lastRecord && samePayload(last, payload)) { try { await store.setSessionStatus(sessionId, 'completed', timestamp, { expectedLastSeq: lastRecord.seq, }); } catch (error) { if (error instanceof RuntimeAppendConflictError) continue; throw error; } return; } try { await store.appendRecord( { id: mintRecordId(), sessionId, sceneId: input.sceneId, createdAt: timestamp, payload, }, { expectedLastSeq: lastRecord?.seq ?? null, ...(payload.phase === 'reviewed' ? { sessionTransition: { status: 'completed' as const, updatedAt: timestamp } } : {}), }, ); } catch (error) { if (error instanceof RuntimeAppendConflictError) continue; if (!isInactiveSessionAppendError(error, sessionId)) throw error; const raced = await store.getSession(sessionId); if (!raced || raced.status === 'active') throw error; assertPartition(raced, input.stageId, learnerKey); // Another tab completed between our active read and append. Re-run // the loop so the immutable completed attempt rolls forward. continue; } return; } if (last && samePayload(last, payload)) return; if ( last && PHASE_ORDER[payload.phase] < PHASE_ORDER[last.phase] && sameAnswers(payload.answers, last.answers) ) { return; } rolloverIndex += 1; sessionId = rolloverAttemptId(rootId, rolloverIndex); } }), ); } /** Backfill the strongest legacy localStorage state without deleting legacy keys. */ export async function backfillQuizAttempt( input: LegacyQuizAttemptInput, deps: QuizAttemptRuntimeDeps = {}, ): Promise { const base = { stageId: input.stageId, sceneId: input.sceneId, attemptId: input.attemptId, }; if (input.submittedAnswers) { await recordQuizAttempt({ ...base, phase: 'submitted', answers: input.submittedAnswers }, deps); if (input.results !== undefined) { await recordQuizAttempt( { ...base, phase: 'reviewed', answers: input.submittedAnswers, results: input.results, }, deps, ); } return; } if (input.draftAnswers) { await recordQuizAttempt({ ...base, phase: 'draft', answers: input.draftAnswers }, deps); } }