Optimize rahvatarkus API, DB schema and data loading

This commit is contained in:
Mihkel Martin Kasterpalu 2025-02-11 15:50:27 +02:00
parent 74cc8a0458
commit 46287d6984
10 changed files with 227 additions and 147 deletions

View file

@ -1,24 +1,44 @@
import { relations } from 'drizzle-orm/relations'; import { relations } from 'drizzle-orm/relations';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'; import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
export const questions = sqliteTable('questions', { export const questions = sqliteTable('questions', {
id: text('id') id: text('id')
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
creator: text('creator').notNull(), // session token creator: text('creator').notNull(),
content: text('content').notNull().unique() content: text('content').notNull().unique(),
answerCount: integer('answer_count').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date())
}); });
export const answers = sqliteTable('answers', { export const answers = sqliteTable('answers', {
id: text('id') id: text('id')
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
creator: text('creator').notNull(), // session token creator: text('creator').notNull(),
content: text('content').notNull(), content: text('content').notNull(),
questionId: text('question_id').references(() => questions.id) questionId: text('question_id')
.notNull()
.references(() => questions.id),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date())
}); });
// Indexes
export const questionsIndexes = {
answerCountIdx: `CREATE INDEX idx_questions_answer_count ON questions (answer_count)`,
creatorIdx: `CREATE INDEX idx_questions_creator ON questions (creator)`
};
export const answersIndexes = {
questionIdIdx: `CREATE INDEX idx_answers_question_id ON answers (question_id)`,
creatorIdx: `CREATE INDEX idx_answers_creator ON answers (creator)`
};
// Relations // Relations
export const questionsRelations = relations(questions, ({ many }) => ({ export const questionsRelations = relations(questions, ({ many }) => ({
answers: many(answers) answers: many(answers)

View file

@ -1,72 +1,62 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema'; import { questions, answers } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
const maxAnswers = 5; const maxAnswers = 5;
export async function POST({ locals, request }) { export async function POST({ locals, request }) {
const { userId, questionId, content }: { userId: string; questionId: string; content: string } = const { userId, questionId, content } = await request.json();
await request.json();
const { session } = locals; const { session } = locals;
if (!session?.data?.userId) { if (!session?.data?.userId) return;
return;
}
const user = session.data.userId; const user = session.data.userId;
if (!user || !userId || user !== userId) { if (!user || !userId || user !== userId) {
return json({ error: 'Unauthorized' }, { status: 401 }); return json({ error: 'Unauthorized' }, { status: 401 });
} }
if (!content) { // Start transaction
return json({ error: 'Answer is required' }, { status: 400 }); return await db.transaction(async (tx) => {
} // Get question and validate in one query
const question = await tx
.select({
creator: questions.creator,
answerCount: questions.answerCount
})
.from(questions)
.where(eq(questions.id, questionId))
.limit(1);
if (!questionId) { if (!question.length) {
return json({ error: 'No question specified' }, { status: 400 }); return json({ error: 'Question not found' }, { status: 400 });
} }
const question = await db const [questionData] = question;
.select({
question: questions
})
.from(questions)
.where(eq(questions.id, questionId))
.limit(1);
if (!question) { // if (questionData.creator === user) {
return json({ error: 'Question not found' }, { status: 400 }); // return json({ error: 'Cannot answer own question' }, { status: 400 });
} // }
if (question[0].question.creator === user) { if (questionData.answerCount >= maxAnswers) {
return json({ error: 'Not allowed to answer your own question' }, { status: 400 }); return json({ error: 'No more answers needed' }, { status: 400 });
} }
const currentAnswers = await db // Insert answer and update count atomically
.select({ const [newAnswer] = await tx
answer: answers
})
.from(answers)
.where(eq(answers.questionId, questionId));
if (currentAnswers.length >= maxAnswers) {
return json({ error: 'No more answers needed for this question' }, { status: 400 });
}
try {
const [newAnswer] = await db
.insert(answers) .insert(answers)
.values({ .values({
content: content, content,
creator: userId, creator: userId,
questionId: questionId questionId
}) })
.returning(); .returning();
await tx
.update(questions)
.set({ answerCount: sql`${questions.answerCount} + 1` })
.where(eq(questions.id, questionId));
return json(newAnswer); return json(newAnswer);
} catch (e) { });
return json({ error: e }, { status: 400 });
}
} }

View file

@ -1,52 +1,48 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema'; import { questions, answers } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm'; import { eq, gt, sql } from 'drizzle-orm';
import type { Question } from '$lib/types'; import type { Question } from '$lib/types';
export async function GET({ params }) { export async function GET({ params }) {
const limit = Math.min(parseInt(params.limit) || 10, 10); const limit = Math.min(parseInt(params.limit) || 10, 10);
const offset = parseInt(params.offset) || 0; const offset = parseInt(params.offset) || 0;
// Get total count // Get total in parallel with data
const [{ total }] = await db const totalPromise = db
.select({ .select({
total: sql`count(DISTINCT ${questions.id})` count: sql`count(*)`
}) })
.from(questions) .from(questions)
.innerJoin(answers, eq(questions.id, answers.questionId)); .where(gt(questions.answerCount, 0));
const results = await db const questionsPromise = db
.select({ .select({
question: questions, id: questions.id,
answers: answers content: questions.content,
creator: questions.creator,
createdAt: questions.createdAt,
answers: sql`json_group_array(json_object(
'id', ${answers.id},
'content', ${answers.content},
'creator', ${answers.creator},
'createdAt', ${answers.createdAt}
))`
}) })
.from(questions) .from(questions)
.innerJoin(answers, eq(questions.id, answers.questionId)) .innerJoin(answers, eq(questions.id, answers.questionId))
.where(gt(questions.answerCount, 0))
.groupBy(questions.id)
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
// Group answers by question const [total, curr_questions] = await Promise.all([totalPromise, questionsPromise]);
type QuestionMap = {
[key: string]: Question;
};
const questionsWithAnswers = results.reduce<QuestionMap>((acc, row) => { return json({
const questionId = row.question.id; data: curr_questions.map((q) => ({
...q,
if (!acc[questionId]) { answers: JSON.parse(q.answers)
acc[questionId] = { })),
...row.question, meta: { limit, offset, total: total[0].count }
answers: [] });
};
}
if (row.answers) {
acc[questionId].answers.push(row.answers);
}
return acc;
}, {});
return json({ data: Object.values(questionsWithAnswers), meta: { limit, offset, total } });
} }

View file

@ -1,67 +1,109 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { questions, answers } from '$lib/server/db/schema'; import { questions, answers } from '$lib/server/db/schema';
import { eq, count, and, not, sql } from 'drizzle-orm'; import { eq, and, not, sql, exists, lt, gt } from 'drizzle-orm';
export async function GET({ locals }) { export async function GET({ locals }) {
const { session } = locals; const { session } = locals;
if (!session?.data?.userId) return;
if (!session?.data?.userId) {
return;
}
const user = session.data.userId; const user = session.data.userId;
const questionWithAnswerCount = await db // Use the answerCount field and avoid joins
const eligibleQuestions = await db
.select({ .select({
id: questions.id, id: questions.id,
content: questions.content, content: questions.content,
answerCount: count(answers.id) answerCount: questions.answerCount
}) })
.from(questions) .from(questions)
.leftJoin(answers, eq(questions.id, answers.questionId)) .where(
.groupBy(questions.id) and(
.having(and(sql`${count(answers.id)} < 5`, not(eq(questions.creator, user)))) lt(questions.answerCount, 5),
not(
exists(
db
.select()
.from(answers)
.where(and(eq(answers.questionId, questions.id), eq(answers.creator, user)))
)
)
)
)
.orderBy(sql`RANDOM()`) .orderBy(sql`RANDOM()`)
.limit(1); .limit(1);
if (!questionWithAnswerCount.length) { if (!eligibleQuestions.length) {
return json({ error: 'No questions available' }, { status: 404 }); return json({ error: 'No questions available' }, { status: 404 });
} }
return json(questionWithAnswerCount[0]); return json(eligibleQuestions[0]);
} }
export async function POST({ locals, request }) { export async function POST({ locals, request }) {
const { userId, content }: { userId: string; content: string } = await request.json(); const { userId, content }: { userId: string; content: string } = await request.json();
const { session } = locals; const { session } = locals;
if (!session?.data?.userId) { if (!session?.data?.userId) return;
return;
}
const user = session.data.userId; const user = session.data.userId;
if (!user || !userId || user !== userId) { if (!user || !userId || user !== userId) {
console.log(user, userId);
return json({ error: 'Unauthorized' }, { status: 401 }); return json({ error: 'Unauthorized' }, { status: 401 });
} }
if (!content) { if (!content?.trim()) {
return json({ error: 'Content is required' }, { status: 400 }); return json({ error: 'Content is required' }, { status: 400 });
} }
// Normalize content
const normalizedContent = content.trim();
const finalContent =
normalizedContent.at(-1) === '?' ? normalizedContent.slice(0, -1) : normalizedContent;
try { try {
const [newQuestion] = await db // Use transaction to ensure data consistency
.insert(questions) const [newQuestion] = await db.transaction(async (tx) => {
.values({ // Check for duplicate questions first
content: content.at(-1) === '?' ? content.slice(0, -1) : content, const existingQuestion = await tx
creator: userId .select({ id: questions.id })
}) .from(questions)
.returning(); .where(eq(questions.content, finalContent))
.limit(1);
if (existingQuestion.length > 0) {
throw new Error('Question already exists');
}
// Check user's recent questions (optional rate limiting)
const recentQuestions = await tx
.select({ count: sql`count(*)` })
.from(questions)
.where(
and(
eq(questions.creator, userId),
gt(questions.createdAt, sql`datetime('now', '-1 hour')`)
)
);
if (recentQuestions[0].count >= 10) {
throw new Error('Too many questions in the last hour');
}
// Insert the new question
return await tx
.insert(questions)
.values({
content: finalContent,
creator: userId,
answerCount: 0,
createdAt: new Date()
})
.returning();
});
return json(newQuestion); return json(newQuestion);
} catch (e) { } catch (e) {
return json({ error: e }, { status: 400 }); const error = e instanceof Error ? e.message : 'Failed to create question';
return json({ error }, { status: 400 });
} }
} }

View file

@ -0,0 +1,43 @@
import type { LayoutServerLoad } from './$types';
import type { Question } from '$lib/types';
import { formSchema as questionSchema } from './question-schema';
import { formSchema as answerSchema } from './answer-schema';
import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { nanoid } from 'nanoid';
export const load: LayoutServerLoad = async ({ fetch, locals }) => {
const { session } = locals;
if (!session?.data?.userId) {
await session.setData({ userId: nanoid() });
await session.save();
}
const user = session.data.userId;
const res = await fetch('/api/rahvatarkus/question')
.then(async (res) => {
const data = await res.json();
return { ok: res.ok, data: data };
})
.then((data) => {
return data;
});
let question: Question | undefined = undefined;
if (res.ok) {
question = res.data;
}
return {
user: user,
question: question,
question_form: await superValidate(zod(questionSchema)),
answer_form: await superValidate({ questionId: question?.id }, zod(answerSchema), {
errors: false
})
};
};

View file

@ -0,0 +1,13 @@
<script lang="ts">
import QuestionForm from './question-form.svelte';
import AnswerForm from './answer-form.svelte';
let { data, children } = $props();
$inspect(data);
</script>
<QuestionForm {data} />
<AnswerForm {data} />
{@render children()}

View file

@ -1,41 +1,17 @@
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import type { Question } from '$lib/types';
import { formSchema as questionSchema } from './question-schema'; import { formSchema as questionSchema } from './question-schema';
import { formSchema as answerSchema } from './answer-schema'; import { formSchema as answerSchema } from './answer-schema';
import { superValidate } from 'sveltekit-superforms'; import { superValidate } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters'; import { zod } from 'sveltekit-superforms/adapters';
import { nanoid } from 'nanoid';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
const perPage = 5; const pageSize = 5;
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, url }) => {
const { session } = locals; const page = Number(url.searchParams.get('leht')) || 0;
if (!session?.data?.userId) { const archiveRes = fetch(`/api/rahvatarkus/archive/${pageSize}/${(page - 1) * pageSize}`)
await session.setData({ userId: nanoid() });
await session.save();
}
const user = session.data.userId;
const res = await fetch('/api/rahvatarkus/question')
.then(async (res) => {
const data = await res.json();
return { ok: res.ok, data: data };
})
.then((data) => {
return data;
});
let question: Question | undefined = undefined;
if (res.ok) {
question = res.data;
}
const archiveRes = fetch(`/api/rahvatarkus/archive/${perPage}`)
.then((res) => { .then((res) => {
return res.json(); return res.json();
}) })
@ -44,13 +20,8 @@ export const load: PageServerLoad = async ({ fetch, locals }) => {
}); });
return { return {
user: user, page,
question: question, pageSize,
question_form: await superValidate(zod(questionSchema)),
answer_form: await superValidate({ questionId: question?.id }, zod(answerSchema), {
errors: false
}),
perPage,
streamed: { streamed: {
archive: archiveRes archive: archiveRes
} }

View file

@ -4,18 +4,13 @@
import * as Accordion from '$lib/components/ui/accordion/index.js'; import * as Accordion from '$lib/components/ui/accordion/index.js';
import * as Pagination from '$lib/components/ui/pagination/index.js'; import * as Pagination from '$lib/components/ui/pagination/index.js';
import QuestionForm from './question-form.svelte'; import { goto } from '$app/navigation';
import AnswerForm from './answer-form.svelte';
import SuperDebug from 'sveltekit-superforms';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
$inspect(data); $inspect(data);
</script> </script>
<QuestionForm {data} />
<AnswerForm {data} />
{#await data.streamed.archive} {#await data.streamed.archive}
<p>loading</p> <p>loading</p>
{:then archive} {:then archive}
@ -35,7 +30,14 @@
</Accordion.Item> </Accordion.Item>
{/each} {/each}
</Accordion.Root> </Accordion.Root>
<Pagination.Root count={archive.meta.total} perPage={data.perPage}> <Pagination.Root
onPageChange={(value: number) => {
goto(`?leht=${value}`);
}}
count={archive.meta.total}
perPage={data.pageSize}
page={data.page}
>
{#snippet children({ pages, currentPage })} {#snippet children({ pages, currentPage })}
<Pagination.Content> <Pagination.Content>
<Pagination.Item> <Pagination.Item>

View file

@ -14,6 +14,7 @@
const form = superForm(data.answer_form, { const form = superForm(data.answer_form, {
validators: zodClient(formSchema), validators: zodClient(formSchema),
invalidateAll: 'force',
onUpdated: ({ form: f }) => { onUpdated: ({ form: f }) => {
if (f.valid) { if (f.valid) {
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`); toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);

View file

@ -12,6 +12,8 @@
const form = superForm(data.question_form, { const form = superForm(data.question_form, {
validators: zodClient(formSchema), validators: zodClient(formSchema),
invalidateAll: false,
resetForm: true,
onUpdated: ({ form: f }) => { onUpdated: ({ form: f }) => {
if (f.valid) { if (f.valid) {
toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`); toast.success(`You submitted ${JSON.stringify(f.data, null, 2)}`);