question-bank-framework 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,140 +1,150 @@
1
- // Quiz persistence: Convex when VITE_CONVEX_URL is set, localStorage fallback.
2
- // Namespaced per bank via `namespace` so multiple banks can coexist.
1
+ // Quiz persistence. localStorage is always the working copy; when the bank
2
+ // sets `sync`, every change is also pushed to the server and the two are
3
+ // reconciled (newest `updatedAt` wins, deletions are tombstoned server-side).
3
4
  // Framework-owned: eject with `qb reveal framework/lib/quiz-storage.ts`.
4
5
 
5
- import { ConvexHttpClient } from 'convex/browser';
6
- import { convexApi } from './convex-api.ts';
7
6
  import type { QuizSession } from '../../core/types.ts';
8
-
9
- const convexUrl = import.meta.env?.VITE_CONVEX_URL || (import.meta.env?.CONVEX_URL as string | undefined);
10
- const convexClient = convexUrl ? new ConvexHttpClient(convexUrl) : null;
11
-
12
- type QuizDocument = QuizSession & { _id: string; _creationTime: number };
7
+ import { readLocal, syncEnabled, syncRequest, writeLocal, type StorageTarget } from './sync-client.ts';
13
8
 
14
9
  const keyFor = (namespace: string) => `${namespace}-question-bank-quizzes`;
10
+ const pendingDeletesKeyFor = (namespace: string) => `${namespace}-question-bank-quiz-deletes`;
15
11
 
16
- const readLocalQuizzes = (namespace: string): QuizSession[] => {
17
- if (typeof window === 'undefined') return [];
18
- try {
19
- const value: unknown = JSON.parse(window.localStorage.getItem(keyFor(namespace)) ?? '[]');
20
- return Array.isArray(value) ? (value as QuizSession[]) : [];
21
- } catch {
22
- return [];
23
- }
24
- };
12
+ const isQuizList = (value: unknown): value is QuizSession[] => Array.isArray(value);
13
+ const isStringList = (value: unknown): value is string[] =>
14
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
25
15
 
26
- const writeLocalQuizzes = (namespace: string, quizzes: QuizSession[]) => {
27
- if (typeof window !== 'undefined') window.localStorage.setItem(keyFor(namespace), JSON.stringify(quizzes));
28
- };
16
+ const readLocalQuizzes = (namespace: string): QuizSession[] => readLocal(keyFor(namespace), [], isQuizList);
17
+ const writeLocalQuizzes = (namespace: string, quizzes: QuizSession[]) => writeLocal(keyFor(namespace), sortQuizzes(quizzes));
18
+ const readPendingDeletes = (namespace: string): string[] => readLocal(pendingDeletesKeyFor(namespace), [], isStringList);
19
+ const writePendingDeletes = (namespace: string, ids: string[]) => writeLocal(pendingDeletesKeyFor(namespace), ids);
29
20
 
30
- export function saveQuizLocally(namespace: string, session: QuizSession): void {
21
+ const versionOf = (quiz: QuizSession): number => quiz.updatedAt ?? quiz.createdAt;
22
+ const sortQuizzes = (quizzes: QuizSession[]) => [...quizzes].sort((a, b) => b.createdAt - a.createdAt);
23
+
24
+ function upsertLocal(namespace: string, session: QuizSession): void {
31
25
  writeLocalQuizzes(namespace, [session, ...readLocalQuizzes(namespace).filter((quiz) => quiz.id !== session.id)]);
32
26
  }
33
27
 
34
- export async function deleteQuiz(namespace: string, id: string): Promise<void> {
35
- if (convexClient && !id.startsWith('local_')) {
36
- await convexClient.mutation(convexApi.quizzes.remove, { quizId: id });
28
+ function removeLocal(namespace: string, id: string): void {
29
+ writeLocalQuizzes(namespace, readLocalQuizzes(namespace).filter((quiz) => quiz.id !== id));
30
+ }
31
+
32
+ /** Push one quiz. The server keeps the newer copy and returns what it stored (410 when deleted). */
33
+ async function pushQuiz(target: StorageTarget, session: QuizSession): Promise<void> {
34
+ if (!syncEnabled(target)) return;
35
+ const { status, data } = await syncRequest<QuizSession>(target, `/quizzes/${encodeURIComponent(session.id)}`, {
36
+ method: 'PUT',
37
+ body: { ...session, updatedAt: versionOf(session) },
38
+ });
39
+ if (status === 410) {
40
+ removeLocal(target.namespace, session.id);
41
+ return;
42
+ }
43
+ const local = readLocalQuizzes(target.namespace).find((quiz) => quiz.id === session.id);
44
+ if (!local || versionOf(data) > versionOf(local)) upsertLocal(target.namespace, data);
45
+ }
46
+
47
+ /**
48
+ * Two-way merge: retry pending deletes, adopt newer server copies, drop
49
+ * server-deleted quizzes, and upload local quizzes the server lacks or has
50
+ * an older copy of. Falls back to the local list when the server is unreachable.
51
+ */
52
+ async function reconcile(target: StorageTarget): Promise<QuizSession[]> {
53
+ const namespace = target.namespace;
54
+ if (!syncEnabled(target)) return sortQuizzes(readLocalQuizzes(namespace));
55
+ try {
56
+ const pending = readPendingDeletes(namespace);
57
+ for (const id of pending) {
58
+ await syncRequest(target, `/quizzes/${encodeURIComponent(id)}`, { method: 'DELETE' });
59
+ }
60
+ if (pending.length > 0) writePendingDeletes(namespace, []);
61
+
62
+ const { data } = await syncRequest<{ quizzes: QuizSession[]; deleted: string[] }>(target, '/quizzes');
63
+ const deleted = new Set(data.deleted);
64
+ const remote = new Map(data.quizzes.map((quiz) => [quiz.id, quiz]));
65
+ const merged = new Map<string, QuizSession>(remote);
66
+ const uploads: QuizSession[] = [];
67
+ for (const local of readLocalQuizzes(namespace)) {
68
+ if (deleted.has(local.id)) continue;
69
+ const server = remote.get(local.id);
70
+ if (!server || versionOf(local) > versionOf(server)) {
71
+ merged.set(local.id, local);
72
+ uploads.push(local);
73
+ }
74
+ }
75
+ writeLocalQuizzes(namespace, [...merged.values()]);
76
+ await Promise.all(uploads.map((quiz) => pushQuiz(target, quiz)));
77
+ return sortQuizzes(readLocalQuizzes(namespace));
78
+ } catch {
79
+ return sortQuizzes(readLocalQuizzes(namespace));
37
80
  }
38
- writeLocalQuizzes(
39
- namespace,
40
- readLocalQuizzes(namespace).filter((quiz) => quiz.id !== id),
41
- );
42
81
  }
43
82
 
44
- const fromDocument = (document: QuizDocument): QuizSession => ({
45
- id: document._id,
46
- title: document.title,
47
- questionIds: document.questionIds,
48
- answers: document.answers,
49
- currentQuestion: document.currentQuestion,
50
- completed: document.completed,
51
- createdAt: document.createdAt ?? document._creationTime,
52
- });
53
-
54
- export async function createQuiz(namespace: string, title: string, questionIds: string[]): Promise<QuizSession> {
55
- const localSession: QuizSession = {
56
- id: `local_${crypto.randomUUID()}`,
83
+ export async function createQuiz(target: StorageTarget, title: string, questionIds: string[]): Promise<QuizSession> {
84
+ const now = Date.now();
85
+ const session: QuizSession = {
86
+ id: crypto.randomUUID(),
57
87
  title,
58
88
  questionIds,
59
89
  answers: {},
60
90
  currentQuestion: 0,
61
91
  completed: false,
62
- createdAt: Date.now(),
92
+ createdAt: now,
93
+ updatedAt: now,
63
94
  };
95
+ upsertLocal(target.namespace, session);
96
+ await pushQuiz(target, session).catch(() => {
97
+ // Stays local; the next reconcile uploads it.
98
+ });
99
+ return session;
100
+ }
64
101
 
65
- if (convexClient) {
66
- try {
67
- const id = await convexClient.mutation(convexApi.quizzes.create, { title, questionIds });
68
- return { ...localSession, id };
69
- } catch {
70
- writeLocalQuizzes(namespace, [localSession, ...readLocalQuizzes(namespace)]);
71
- return localSession;
72
- }
73
- }
102
+ export async function getQuiz(target: StorageTarget, id: string): Promise<QuizSession | null> {
103
+ const quizzes = await reconcile(target);
104
+ return quizzes.find((quiz) => quiz.id === id) ?? null;
105
+ }
106
+
107
+ export async function listQuizzes(target: StorageTarget): Promise<QuizSession[]> {
108
+ return reconcile(target);
109
+ }
110
+
111
+ /** Record a change locally (stamps `updatedAt`). Call `syncQuiz` afterwards to push it. */
112
+ export function saveQuizLocally(target: StorageTarget, session: QuizSession): QuizSession {
113
+ const updated = { ...session, updatedAt: Math.max(Date.now(), versionOf(session) + 1) };
114
+ upsertLocal(target.namespace, updated);
115
+ return updated;
116
+ }
74
117
 
75
- writeLocalQuizzes(namespace, [localSession, ...readLocalQuizzes(namespace)]);
76
- return localSession;
118
+ /** Push a locally saved quiz. A failed push stays local; the next reconcile uploads it. */
119
+ export async function syncQuiz(target: StorageTarget, session: QuizSession): Promise<void> {
120
+ await pushQuiz(target, session).catch(() => {});
77
121
  }
78
122
 
79
- export async function getQuiz(namespace: string, id: string): Promise<QuizSession | null> {
80
- const localSession = readLocalQuizzes(namespace).find((quiz) => quiz.id === id);
81
- if (id.startsWith('local_') || !convexClient) return localSession ?? null;
123
+ /** saveQuizLocally + syncQuiz. */
124
+ export async function saveQuiz(target: StorageTarget, session: QuizSession): Promise<QuizSession> {
125
+ const updated = saveQuizLocally(target, session);
126
+ await syncQuiz(target, updated);
127
+ return updated;
128
+ }
129
+
130
+ export async function deleteQuiz(target: StorageTarget, id: string): Promise<void> {
131
+ removeLocal(target.namespace, id);
132
+ if (!syncEnabled(target)) return;
82
133
  try {
83
- const document = await convexClient.query(convexApi.quizzes.get, { quizId: id });
84
- return document ? fromDocument(document) : (localSession ?? null);
134
+ await syncRequest(target, `/quizzes/${encodeURIComponent(id)}`, { method: 'DELETE' });
85
135
  } catch {
86
- return localSession ?? null;
136
+ writePendingDeletes(target.namespace, [...new Set([...readPendingDeletes(target.namespace), id])]);
87
137
  }
88
138
  }
89
139
 
90
- export async function saveQuizProgress(
91
- namespace: string,
92
- session: QuizSession,
93
- progress: Pick<QuizSession, 'title' | 'answers' | 'currentQuestion' | 'completed'>,
94
- ): Promise<void> {
95
- const updated = { ...session, ...progress };
96
- saveQuizLocally(namespace, updated);
97
- if (!convexClient || updated.id.startsWith('local_')) return;
98
- await convexClient.mutation(convexApi.quizzes.saveProgress, {
99
- quizId: updated.id,
100
- title: updated.title,
101
- answers: updated.answers,
102
- currentQuestion: updated.currentQuestion,
103
- completed: updated.completed,
104
- });
105
- }
106
-
107
140
  // Replaces a quiz's question list (reorder / remove / append). Answers for
108
141
  // removed questions are dropped and the current position is kept on the same
109
142
  // question where possible.
110
- export async function setQuizQuestions(namespace: string, session: QuizSession, questionIds: string[]): Promise<QuizSession> {
143
+ export async function setQuizQuestions(target: StorageTarget, session: QuizSession, questionIds: string[]): Promise<QuizSession> {
111
144
  const kept = new Set(questionIds);
112
145
  const answers = Object.fromEntries(Object.entries(session.answers).filter(([id]) => kept.has(id)));
113
146
  const currentId = session.questionIds[session.currentQuestion];
114
147
  const currentIndex = currentId === undefined ? -1 : questionIds.indexOf(currentId);
115
148
  const currentQuestion = currentIndex >= 0 ? currentIndex : Math.min(session.currentQuestion, Math.max(questionIds.length - 1, 0));
116
- const updated: QuizSession = { ...session, questionIds, answers, currentQuestion };
117
- if (convexClient && !updated.id.startsWith('local_')) {
118
- await convexClient.mutation(convexApi.quizzes.setQuestions, {
119
- quizId: updated.id,
120
- questionIds,
121
- answers,
122
- currentQuestion,
123
- });
124
- }
125
- saveQuizLocally(namespace, updated);
126
- return updated;
127
- }
128
-
129
- export async function listQuizzes(namespace: string): Promise<QuizSession[]> {
130
- const localQuizzes = readLocalQuizzes(namespace);
131
- if (!convexClient) return localQuizzes;
132
- try {
133
- const documents = await convexClient.query(convexApi.quizzes.list, {});
134
- const remoteQuizzes = documents.map(fromDocument);
135
- const ids = new Set(remoteQuizzes.map((quiz) => quiz.id));
136
- return [...remoteQuizzes, ...localQuizzes.filter((quiz) => !ids.has(quiz.id))];
137
- } catch {
138
- return localQuizzes;
139
- }
149
+ return saveQuiz(target, { ...session, questionIds, answers, currentQuestion });
140
150
  }
@@ -0,0 +1,45 @@
1
+ // HTTP client for the framework's sync API (see framework/cloudflare/).
2
+ // Framework-owned: eject with `qb reveal framework/lib/sync-client.ts`.
3
+
4
+ import type { Bank } from '../../core/types.ts';
5
+
6
+ /** What the storage modules need from a bank: its namespace and optional sync endpoint. */
7
+ export type StorageTarget = Pick<Bank, 'namespace' | 'sync'>;
8
+
9
+ export function syncEnabled(target: StorageTarget): boolean {
10
+ return typeof window !== 'undefined' && !!target.sync?.url;
11
+ }
12
+
13
+ /** JSON request against `<sync.url>/<namespace><path>`. Throws on network or HTTP errors. */
14
+ export async function syncRequest<T>(
15
+ target: StorageTarget,
16
+ path: string,
17
+ init?: { method?: 'GET' | 'PUT' | 'DELETE'; body?: unknown },
18
+ ): Promise<{ status: number; data: T }> {
19
+ if (!target.sync?.url) throw new Error('Sync is not configured for this bank.');
20
+ const base = target.sync.url.replace(/\/+$/, '');
21
+ const response = await fetch(`${base}/${encodeURIComponent(target.namespace)}${path}`, {
22
+ method: init?.method ?? 'GET',
23
+ headers: init?.body === undefined ? undefined : { 'content-type': 'application/json' },
24
+ body: init?.body === undefined ? undefined : JSON.stringify(init.body),
25
+ credentials: 'same-origin',
26
+ });
27
+ if (!response.ok && response.status !== 410) {
28
+ throw new Error(`Sync request failed: ${response.status} ${response.statusText}`);
29
+ }
30
+ return { status: response.status, data: (await response.json()) as T };
31
+ }
32
+
33
+ export function readLocal<T>(key: string, fallback: T, valid: (value: unknown) => value is T): T {
34
+ if (typeof window === 'undefined') return fallback;
35
+ try {
36
+ const value: unknown = JSON.parse(window.localStorage.getItem(key) ?? 'null');
37
+ return valid(value) ? value : fallback;
38
+ } catch {
39
+ return fallback;
40
+ }
41
+ }
42
+
43
+ export function writeLocal(key: string, value: unknown): void {
44
+ if (typeof window !== 'undefined') window.localStorage.setItem(key, JSON.stringify(value));
45
+ }
@@ -7,6 +7,10 @@
7
7
  * @import 'question-bank-framework/framework/styles/theme.css';
8
8
  */
9
9
 
10
+ /* KaTeX CSS from the framework's own katex, matching the version that renders
11
+ * math in MarkdownContent. Don't import katex CSS separately in your app. */
12
+ @import 'katex/dist/katex.min.css';
13
+
10
14
  @theme inline {
11
15
  --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
12
16
  --font-display: 'Fraunces', Georgia, serif;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  "./core": "./core/index.ts",
14
14
  "./framework": "./framework/index.ts",
15
15
  "./framework/*": "./framework/*",
16
+ "./cloudflare": "./framework/cloudflare/index.ts",
16
17
  "./template/*": "./template/*"
17
18
  },
18
19
  "bin": {
@@ -35,12 +36,10 @@
35
36
  "@tanstack/react-virtual": "^3.14.19",
36
37
  "class-variance-authority": "^0.7.1",
37
38
  "clsx": "^2.1.1",
38
- "convex": "^1.46.0",
39
39
  "katex": "^0.19.0",
40
40
  "lucide-react": "^1.57.0",
41
41
  "radix-ui": "^1.8.1",
42
42
  "react-markdown": "^10.1.0",
43
- "rehype-katex": "^7.0.1",
44
43
  "remark-gfm": "^4.0.1",
45
44
  "remark-math": "^6.0.0",
46
45
  "tailwind-merge": "^3.7.0",
@@ -25,6 +25,8 @@ export const bank = defineBank({
25
25
  description: 'A searchable, section-classified question bank built from COMP20008 past exams.',
26
26
  },
27
27
  namespace: 'comp20008',
28
+ // Sync quizzes + statuses to the Durable Object served by src/server.ts.
29
+ sync: { url: '/api/qb' },
28
30
  fields: [
29
31
  {
30
32
  id: 'year',
@@ -1,5 +1,4 @@
1
1
  @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
2
- @import 'katex/dist/katex.min.css';
3
2
  @import 'tailwindcss';
4
3
  @import 'tw-animate-css';
5
4
  @import 'question-bank-framework/framework/styles/theme.css';
@@ -0,0 +1,17 @@
1
+ // Worker entry: serves the question bank sync API under /api/qb (matching
2
+ // `sync: { url: '/api/qb' }` in bank.config.ts) and everything else through
3
+ // TanStack Start.
4
+ //
5
+ // Before deploying publicly, gate the API: pass `authorize` (e.g. verify a
6
+ // Cloudflare Access JWT) or put the whole Worker behind Cloudflare Access.
7
+
8
+ import handler from '@tanstack/react-start/server-entry';
9
+ import { handleQuestionBankRequest } from 'question-bank-framework/cloudflare';
10
+
11
+ export { QuestionBankStore } from 'question-bank-framework/cloudflare';
12
+
13
+ export default {
14
+ async fetch(request: Request, env: object) {
15
+ return (await handleQuestionBankRequest(request, env)) ?? handler.fetch(request);
16
+ },
17
+ };
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vite';
2
+ import { cloudflare } from '@cloudflare/vite-plugin';
3
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite';
4
+ import viteReact from '@vitejs/plugin-react';
5
+ import tailwindcss from '@tailwindcss/vite';
6
+
7
+ export default defineConfig({
8
+ resolve: { tsconfigPaths: true },
9
+ plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tailwindcss(), tanstackStart(), viteReact()],
10
+ });
@@ -0,0 +1,16 @@
1
+ // Cloudflare Workers config. `pnpm dev` runs this locally in workerd (via
2
+ // @cloudflare/vite-plugin) and persists the sync store to .wrangler/state;
3
+ // `pnpm build && wrangler deploy` publishes it. Rename "name" for your bank.
4
+ {
5
+ "$schema": "node_modules/wrangler/config-schema.json",
6
+ "name": "question-bank",
7
+ "compatibility_date": "2026-11-14",
8
+ "compatibility_flags": ["nodejs_compat"],
9
+ "main": "src/server.ts",
10
+ "observability": { "enabled": true },
11
+ // Quizzes + question statuses: one SQLite-backed Durable Object per bank namespace.
12
+ "durable_objects": {
13
+ "bindings": [{ "name": "QB_STORE", "class_name": "QuestionBankStore" }]
14
+ },
15
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["QuestionBankStore"] }]
16
+ }
@@ -1,30 +0,0 @@
1
- import { mutationGeneric as mutation, queryGeneric as query } from 'convex/server'
2
- import { v } from 'convex/values'
3
-
4
- const statusValidator = v.union(v.literal('unanswered'), v.literal('revisit'), v.literal('complete'))
5
-
6
- export const list = query({
7
- args: {},
8
- handler: async (ctx) => await ctx.db.query('questionStatuses').collect(),
9
- })
10
-
11
- export const set = mutation({
12
- args: {
13
- questionId: v.string(),
14
- status: statusValidator,
15
- },
16
- handler: async (ctx, { questionId, status }) => {
17
- const existing = await ctx.db.query('questionStatuses')
18
- .withIndex('by_question_id', (q) => q.eq('questionId', questionId))
19
- .first()
20
- const updatedAt = Date.now()
21
-
22
- if (existing) {
23
- await ctx.db.patch(existing._id, { status, updatedAt })
24
- } else {
25
- await ctx.db.insert('questionStatuses', { questionId, status, updatedAt })
26
- }
27
-
28
- return updatedAt
29
- },
30
- })
@@ -1,62 +0,0 @@
1
- import { mutationGeneric as mutation, queryGeneric as query } from 'convex/server'
2
- import { v } from 'convex/values'
3
-
4
- export const create = mutation({
5
- args: {
6
- title: v.string(),
7
- questionIds: v.array(v.string()),
8
- },
9
- handler: async (ctx, { title, questionIds }) => {
10
- const createdAt = Date.now()
11
- return await ctx.db.insert('quizzes', {
12
- title,
13
- questionIds,
14
- answers: {},
15
- currentQuestion: 0,
16
- completed: false,
17
- createdAt,
18
- })
19
- },
20
- })
21
-
22
- export const get = query({
23
- args: { quizId: v.id('quizzes') },
24
- handler: async (ctx, { quizId }) => await ctx.db.get(quizId),
25
- })
26
-
27
- export const list = query({
28
- args: {},
29
- handler: async (ctx) => await ctx.db.query('quizzes').withIndex('by_created_at').order('desc').collect(),
30
- })
31
-
32
- export const remove = mutation({
33
- args: { quizId: v.id('quizzes') },
34
- handler: async (ctx, { quizId }) => {
35
- await ctx.db.delete(quizId)
36
- },
37
- })
38
-
39
- export const saveProgress = mutation({
40
- args: {
41
- quizId: v.id('quizzes'),
42
- title: v.string(),
43
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
44
- currentQuestion: v.number(),
45
- completed: v.boolean(),
46
- },
47
- handler: async (ctx, { quizId, title, answers, currentQuestion, completed }) => {
48
- await ctx.db.patch(quizId, { title, answers, currentQuestion, completed })
49
- },
50
- })
51
-
52
- export const setQuestions = mutation({
53
- args: {
54
- quizId: v.id('quizzes'),
55
- questionIds: v.array(v.string()),
56
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
57
- currentQuestion: v.number(),
58
- },
59
- handler: async (ctx, { quizId, questionIds, answers, currentQuestion }) => {
60
- await ctx.db.patch(quizId, { questionIds, answers, currentQuestion })
61
- },
62
- })
@@ -1,18 +0,0 @@
1
- import { defineSchema, defineTable } from 'convex/server'
2
- import { v } from 'convex/values'
3
-
4
- export default defineSchema({
5
- quizzes: defineTable({
6
- title: v.string(),
7
- questionIds: v.array(v.string()),
8
- answers: v.record(v.string(), v.union(v.string(), v.array(v.string()), v.record(v.string(), v.string()))),
9
- currentQuestion: v.number(),
10
- completed: v.boolean(),
11
- createdAt: v.number(),
12
- }).index('by_created_at', ['createdAt']),
13
- questionStatuses: defineTable({
14
- questionId: v.string(),
15
- status: v.union(v.literal('unanswered'), v.literal('revisit'), v.literal('complete')),
16
- updatedAt: v.number(),
17
- }).index('by_question_id', ['questionId']),
18
- })
@@ -1,43 +0,0 @@
1
- // Typed Convex function references. No value imports — safe to eject.
2
- // Framework-owned: eject with `qb reveal framework/lib/convex-api.ts`.
3
-
4
- import { makeFunctionReference } from 'convex/server';
5
- import type { QuestionStatus, QuizAnswer, QuizSession } from '../../core/types.ts';
6
-
7
- type CreateQuizArgs = { title: string; questionIds: string[] };
8
- type ProgressArgs = {
9
- quizId: string;
10
- title: string;
11
- answers: Record<string, QuizAnswer>;
12
- currentQuestion: number;
13
- completed: boolean;
14
- };
15
- type SetQuestionsArgs = {
16
- quizId: string;
17
- questionIds: string[];
18
- answers: Record<string, QuizAnswer>;
19
- currentQuestion: number;
20
- };
21
- type QuizDocument = QuizSession & { _id: string; _creationTime: number };
22
- type QuestionStatusDocument = {
23
- _id: string;
24
- _creationTime: number;
25
- questionId: string;
26
- status: QuestionStatus;
27
- updatedAt: number;
28
- };
29
-
30
- export const convexApi = {
31
- quizzes: {
32
- create: makeFunctionReference<'mutation', CreateQuizArgs, string>('quizzes:create'),
33
- get: makeFunctionReference<'query', { quizId: string }, QuizDocument | null>('quizzes:get'),
34
- list: makeFunctionReference<'query', Record<string, never>, QuizDocument[]>('quizzes:list'),
35
- remove: makeFunctionReference<'mutation', { quizId: string }, null>('quizzes:remove'),
36
- saveProgress: makeFunctionReference<'mutation', ProgressArgs, null>('quizzes:saveProgress'),
37
- setQuestions: makeFunctionReference<'mutation', SetQuestionsArgs, null>('quizzes:setQuestions'),
38
- },
39
- questionStatuses: {
40
- list: makeFunctionReference<'query', Record<string, never>, QuestionStatusDocument[]>('questionStatuses:list'),
41
- set: makeFunctionReference<'mutation', { questionId: string; status: QuestionStatus }, number>('questionStatuses:set'),
42
- },
43
- };