Compare commits

..

10 commits

Author SHA1 Message Date
lila
bc38137a12 feat: add production deployment config
- Add docker-compose.prod.yml and Caddyfile for Caddy reverse proxy
- Add production stages to frontend Dockerfile (nginx for static files)
- Fix monorepo package exports for production builds (dist/src paths)
- Add CORS_ORIGIN env var for cross-origin config
- Add Better Auth baseURL, cookie domain, and trusted origins from env
- Use VITE_API_URL for API calls in auth-client and play route
- Add credentials: include for cross-origin fetch requests
- Remove unused users table from schema
2026-04-14 11:38:40 +02:00
lila
3f7bc4111e chore: rename project from glossa to lila
- Update all package names from @glossa/* to @lila/*
- Update all imports, container names, volume names
- Update documentation references
- Recreate database with new credentials
2026-04-13 10:00:52 +02:00
lila
1699f78f0b updating current state, phase 3 is done 2026-04-12 13:41:09 +02:00
lila
a3685a9e68 feat(api): add auth middleware to protect game endpoints
- Add requireAuth middleware using Better Auth session validation
- Apply to all game routes (start, answer)
- Unauthenticated requests return 401
2026-04-12 13:38:32 +02:00
lila
91a3112d8b feat(api): integrate Better Auth with Drizzle adapter and social providers
- Add Better Auth config with Google + GitHub social providers
- Mount auth handler on /api/auth/* in Express
- Generate and migrate auth tables (user, session, account, verification)
- Deduplicate term_glosses data for tighter unique constraint
- Drop legacy users table
2026-04-12 11:46:38 +02:00
lila
cbe638b1af docs: update auth references from OpenAuth to Better Auth 2026-04-12 10:18:16 +02:00
lila
2058d0d542 updating docs 2026-04-12 09:35:14 +02:00
lila
047196c973 updating documentation, formatting 2026-04-12 09:28:35 +02:00
lila
e320f43d8e test(api): add unit and integration tests for game service and endpoints
- Unit tests for createGameSession and evaluateAnswer (14 tests)
- Endpoint tests for POST /game/start and /game/answer via supertest (8 tests)
- Mock @glossa/db — no real database dependency
2026-04-12 09:04:41 +02:00
lila
48457936e8 feat(api): add global error handler with typed error classes
- Add AppError base class, ValidationError (400), NotFoundError (404)
- Add central error middleware in app.ts
- Remove inline safeParse error handling from controllers
- Replace plain Error throws with NotFoundError in gameService
2026-04-12 08:48:43 +02:00
61 changed files with 424586 additions and 2377 deletions

View file

@ -1,5 +1,12 @@
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/postgres
DATABASE_URL=postgres://postgres:mypassword@db-host:5432/databasename
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=databasename
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=

11
Caddyfile Normal file
View file

@ -0,0 +1,11 @@
lilastudy.com {
reverse_proxy web:80
}
api.lilastudy.com {
reverse_proxy api:3000
}
git.lilastudy.com {
reverse_proxy forgejo:3000
}

View file

@ -1 +1 @@
# glossa
# lila

View file

@ -32,11 +32,13 @@ RUN pnpm --filter api build
# 5. run
FROM base AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/package.json /app/packages/shared/package.json
COPY --from=deps /app/packages/db/package.json /app/packages/db/package.json
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/packages/shared/dist /app/packages/shared/dist
COPY --from=builder /app/packages/db/dist /app/packages/db/dist
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY packages/shared/package.json ./packages/shared/
COPY packages/db/package.json ./packages/db/
COPY --from=builder /app/apps/api/dist ./apps/api/dist
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder /app/packages/db/dist ./packages/db/dist
RUN pnpm install --frozen-lockfile --prod
EXPOSE 3000
CMD ["node", "dist/server.js"]
CMD ["node", "apps/api/dist/src/server.js"]

View file

@ -1,20 +1,26 @@
{
"name": "@glossa/api",
"name": "@lila/api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
"start": "node dist/src/server.js",
"test": "vitest"
},
"dependencies": {
"@glossa/db": "workspace:*",
"@glossa/shared": "workspace:*",
"@lila/db": "workspace:*",
"@lila/shared": "workspace:*",
"better-auth": "^1.6.2",
"cors": "^2.8.6",
"express": "^5.2.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/supertest": "^7.2.0",
"supertest": "^7.2.2",
"tsx": "^4.21.0"
}
}

View file

@ -1,11 +1,24 @@
import express from "express";
import type { Express } from "express";
import { toNodeHandler } from "better-auth/node";
import { auth } from "./lib/auth.js";
import { apiRouter } from "./routes/apiRouter.js";
import { errorHandler } from "./middleware/errorHandler.js";
import cors from "cors";
export function createApp() {
const app: Express = express();
app.use(
cors({
origin: process.env["CORS_ORIGIN"] || "http://localhost:5173",
credentials: true,
}),
);
app.all("/api/auth/*splat", toNodeHandler(auth));
app.use(express.json());
app.use("/api/v1", apiRouter);
app.use(errorHandler);
return app;
}

View file

@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
import { getGameTerms, getDistractors } from "@lila/db";
import { createApp } from "../app.js";
const app = createApp();
const mockGetGameTerms = vi.mocked(getGameTerms);
const mockGetDistractors = vi.mocked(getDistractors);
const validBody = {
source_language: "en",
target_language: "it",
pos: "noun",
difficulty: "easy",
rounds: "3",
};
const fakeTerms = [
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
{ termId: "t3", sourceText: "house", targetText: "casa", sourceGloss: null },
];
beforeEach(() => {
vi.clearAllMocks();
mockGetGameTerms.mockResolvedValue(fakeTerms);
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
});
describe("POST /api/v1/game/start", () => {
it("returns 200 with a valid game session", async () => {
const res = await request(app).post("/api/v1/game/start").send(validBody);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.sessionId).toBeDefined();
expect(res.body.data.questions).toHaveLength(3);
});
it("returns 400 when the body is empty", async () => {
const res = await request(app).post("/api/v1/game/start").send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toBeDefined();
});
it("returns 400 when required fields are missing", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ source_language: "en" });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it("returns 400 when a field has an invalid value", async () => {
const res = await request(app)
.post("/api/v1/game/start")
.send({ ...validBody, difficulty: "impossible" });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
describe("POST /api/v1/game/answer", () => {
it("returns 200 with an answer result for a valid submission", async () => {
// Start a game first
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const { sessionId, questions } = startRes.body.data;
const question = questions[0];
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: question.questionId,
selectedOptionId: 0,
});
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.questionId).toBe(question.questionId);
expect(typeof res.body.data.isCorrect).toBe("boolean");
expect(typeof res.body.data.correctOptionId).toBe("number");
expect(res.body.data.selectedOptionId).toBe(0);
});
it("returns 400 when the body is empty", async () => {
const res = await request(app).post("/api/v1/game/answer").send({});
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
it("returns 404 when the session does not exist", async () => {
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId: "00000000-0000-0000-0000-000000000000",
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
});
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain("Game session not found");
});
it("returns 404 when the question does not exist in the session", async () => {
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const { sessionId } = startRes.body.data;
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
});
expect(res.status).toBe(404);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain("Question not found");
});
});

View file

@ -1,32 +1,42 @@
import type { Request, Response } from "express";
import { GameRequestSchema, AnswerSubmissionSchema } from "@glossa/shared";
import type { Request, Response, NextFunction } from "express";
import { GameRequestSchema, AnswerSubmissionSchema } from "@lila/shared";
import { createGameSession, evaluateAnswer } from "../services/gameService.js";
import { ValidationError } from "../errors/AppError.js";
export const createGame = async (req: Request, res: Response) => {
const gameSettings = GameRequestSchema.safeParse(req.body);
export const createGame = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const gameSettings = GameRequestSchema.safeParse(req.body);
// TODO: remove when global error handler is implemented
if (!gameSettings.success) {
res.status(400).json({ success: false });
return;
if (!gameSettings.success) {
throw new ValidationError(gameSettings.error.message);
}
const gameQuestions = await createGameSession(gameSettings.data);
res.json({ success: true, data: gameQuestions });
} catch (error) {
next(error);
}
const gameQuestions = await createGameSession(gameSettings.data);
res.json({ success: true, data: gameQuestions });
};
export const submitAnswer = async (req: Request, res: Response) => {
const submission = AnswerSubmissionSchema.safeParse(req.body);
// TODO: remove when global error handler is implemented
if (!submission.success) {
res.status(400).json({ success: false });
return;
}
export const submitAnswer = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const submission = AnswerSubmissionSchema.safeParse(req.body);
if (!submission.success) {
throw new ValidationError(submission.error.message);
}
const result = await evaluateAnswer(submission.data);
res.json({ success: true, data: result });
} catch (error) {
res.status(404).json({ success: false });
next(error);
}
};

View file

@ -0,0 +1,21 @@
export class AppError extends Error {
public readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class NotFoundError extends AppError {
constructor(message: string) {
super(message, 404);
}
}

30
apps/api/src/lib/auth.ts Normal file
View file

@ -0,0 +1,30 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@lila/db";
import * as schema from "@lila/db/schema";
export const auth = betterAuth({
baseURL: process.env["BETTER_AUTH_URL"] || "http://localhost:3000",
advanced: {
cookiePrefix: "lila",
defaultCookieAttributes: {
...(process.env["COOKIE_DOMAIN"] && {
domain: process.env["COOKIE_DOMAIN"],
}),
secure: !!process.env["COOKIE_DOMAIN"],
sameSite: "lax" as const,
},
},
database: drizzleAdapter(db, { provider: "pg", schema }),
trustedOrigins: [process.env["CORS_ORIGIN"] || "http://localhost:5173"],
socialProviders: {
google: {
clientId: process.env["GOOGLE_CLIENT_ID"] as string,
clientSecret: process.env["GOOGLE_CLIENT_SECRET"] as string,
},
github: {
clientId: process.env["GITHUB_CLIENT_ID"] as string,
clientSecret: process.env["GITHUB_CLIENT_SECRET"] as string,
},
},
});

View file

@ -0,0 +1,20 @@
import type { Request, Response, NextFunction } from "express";
import { fromNodeHeaders } from "better-auth/node";
import { auth } from "../lib/auth.js";
export const requireAuth = async (
req: Request,
res: Response,
next: NextFunction,
) => {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (!session) {
res.status(401).json({ success: false, error: "Unauthorized" });
return;
}
next();
};

View file

@ -0,0 +1,18 @@
import type { Request, Response, NextFunction } from "express";
import { AppError } from "../errors/AppError.js";
export const errorHandler = (
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
) => {
if (err instanceof AppError) {
res.status(err.statusCode).json({ success: false, error: err.message });
return;
}
console.error("Unexpected error:", err);
res.status(500).json({ success: false, error: "Internal server error" });
};

View file

@ -1,8 +1,10 @@
import express from "express";
import type { Router } from "express";
import { createGame, submitAnswer } from "../controllers/gameController.js";
import { requireAuth } from "../middleware/authMiddleware.js";
export const gameRouter: Router = express.Router();
gameRouter.use(requireAuth);
gameRouter.post("/start", createGame);
gameRouter.post("/answer", submitAnswer);

View file

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { GameRequest, AnswerSubmission } from "@lila/shared";
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
import { getGameTerms, getDistractors } from "@lila/db";
import { createGameSession, evaluateAnswer } from "./gameService.js";
const mockGetGameTerms = vi.mocked(getGameTerms);
const mockGetDistractors = vi.mocked(getDistractors);
const validRequest: GameRequest = {
source_language: "en",
target_language: "it",
pos: "noun",
difficulty: "easy",
rounds: "3",
};
const fakeTerms = [
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
{
termId: "t3",
sourceText: "house",
targetText: "casa",
sourceGloss: "a building for living in",
},
];
beforeEach(() => {
vi.clearAllMocks();
mockGetGameTerms.mockResolvedValue(fakeTerms);
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
});
describe("createGameSession", () => {
it("returns a session with the correct number of questions", async () => {
const session = await createGameSession(validRequest);
expect(session.sessionId).toBeDefined();
expect(session.questions).toHaveLength(3);
});
it("each question has exactly 4 options", async () => {
const session = await createGameSession(validRequest);
for (const question of session.questions) {
expect(question.options).toHaveLength(4);
}
});
it("each question has a unique questionId", async () => {
const session = await createGameSession(validRequest);
const ids = session.questions.map((q) => q.questionId);
expect(new Set(ids).size).toBe(ids.length);
});
it("options have sequential optionIds 0-3", async () => {
const session = await createGameSession(validRequest);
for (const question of session.questions) {
const optionIds = question.options.map((o) => o.optionId);
expect(optionIds).toEqual([0, 1, 2, 3]);
}
});
it("the correct answer is always among the options", async () => {
const session = await createGameSession(validRequest);
for (let i = 0; i < session.questions.length; i++) {
const question = session.questions[i]!;
const correctText = fakeTerms[i]!.targetText;
const optionTexts = question.options.map((o) => o.text);
expect(optionTexts).toContain(correctText);
}
});
it("distractors are never the correct answer", async () => {
const session = await createGameSession(validRequest);
for (let i = 0; i < session.questions.length; i++) {
const question = session.questions[i]!;
const correctText = fakeTerms[i]!.targetText;
const distractorTexts = question.options
.map((o) => o.text)
.filter((t) => t !== correctText);
for (const text of distractorTexts) {
expect(text).not.toBe(correctText);
}
}
});
it("sets the prompt from the source text", async () => {
const session = await createGameSession(validRequest);
expect(session.questions[0]!.prompt).toBe("dog");
expect(session.questions[1]!.prompt).toBe("cat");
expect(session.questions[2]!.prompt).toBe("house");
});
it("passes gloss through (null or string)", async () => {
const session = await createGameSession(validRequest);
expect(session.questions[0]!.gloss).toBeNull();
expect(session.questions[2]!.gloss).toBe("a building for living in");
});
it("calls getGameTerms with the correct arguments", async () => {
await createGameSession(validRequest);
expect(mockGetGameTerms).toHaveBeenCalledWith(
"en",
"it",
"noun",
"easy",
3,
);
});
it("calls getDistractors once per question", async () => {
await createGameSession(validRequest);
expect(mockGetDistractors).toHaveBeenCalledTimes(3);
});
});
describe("evaluateAnswer", () => {
it("returns isCorrect: true when the correct option is selected", async () => {
const session = await createGameSession(validRequest);
const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!;
const result = await evaluateAnswer({
sessionId: session.sessionId,
questionId: question.questionId,
selectedOptionId: correctOption.optionId,
});
expect(result.isCorrect).toBe(true);
expect(result.correctOptionId).toBe(correctOption.optionId);
expect(result.selectedOptionId).toBe(correctOption.optionId);
});
it("returns isCorrect: false when a wrong option is selected", async () => {
const session = await createGameSession(validRequest);
const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!;
const wrongOption = question.options.find((o) => o.text !== correctText)!;
const result = await evaluateAnswer({
sessionId: session.sessionId,
questionId: question.questionId,
selectedOptionId: wrongOption.optionId,
});
expect(result.isCorrect).toBe(false);
expect(result.correctOptionId).toBe(correctOption.optionId);
expect(result.selectedOptionId).toBe(wrongOption.optionId);
});
it("throws NotFoundError for a non-existent session", async () => {
const submission: AnswerSubmission = {
sessionId: "00000000-0000-0000-0000-000000000000",
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
};
await expect(evaluateAnswer(submission)).rejects.toThrow(
"Game session not found",
);
});
it("throws NotFoundError for a non-existent question", async () => {
const session = await createGameSession(validRequest);
const submission: AnswerSubmission = {
sessionId: session.sessionId,
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
};
await expect(evaluateAnswer(submission)).rejects.toThrow(
"Question not found",
);
});
});

View file

@ -1,5 +1,5 @@
import { randomUUID } from "crypto";
import { getGameTerms, getDistractors } from "@glossa/db";
import { getGameTerms, getDistractors } from "@lila/db";
import type {
GameRequest,
GameSession,
@ -7,9 +7,9 @@ import type {
AnswerOption,
AnswerSubmission,
AnswerResult,
} from "@glossa/shared";
} from "@lila/shared";
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
import { NotFoundError } from "../errors/AppError.js";
const gameSessionStore = new InMemoryGameSessionStore();
@ -39,7 +39,6 @@ export const createGameSession = async (
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts);
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
@ -64,6 +63,7 @@ export const createGameSession = async (
return { sessionId, questions };
};
const shuffle = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
@ -81,13 +81,13 @@ export const evaluateAnswer = async (
const session = await gameSessionStore.get(submission.sessionId);
if (!session) {
throw new Error(`Game session not found: ${submission.sessionId}`);
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
}
const correctOptionId = session.answers.get(submission.questionId);
if (correctOptionId === undefined) {
throw new Error(`Question not found: ${submission.questionId}`);
throw new NotFoundError(`Question not found: ${submission.questionId}`);
}
return {

View file

@ -2,7 +2,7 @@
"extends": "../../tsconfig.base.json",
"references": [
{ "path": "../../packages/shared" },
{ "path": "../../packages/db" },
{ "path": "../../packages/db" }
],
"compilerOptions": {
"module": "NodeNext",
@ -10,7 +10,7 @@
"outDir": "./dist",
"resolveJsonModule": true,
"rootDir": ".",
"types": ["vitest/globals"],
"types": ["vitest/globals"]
},
"include": ["src", "vitest.config.ts"],
"include": ["src", "vitest.config.ts"]
}

View file

@ -17,3 +17,20 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . ./
EXPOSE 5173
CMD ["pnpm", "--filter", "web", "dev", "--host"]
# 4. Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm install
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter shared build
RUN pnpm --filter web build
# 5. Production — just nginx serving static files
FROM nginx:alpine AS production
COPY --from=builder /app/apps/web/dist /usr/share/nginx/html
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>glossa</title>
<title>lila</title>
<!--TODO: add favicon-->
<link rel="icon" href="data:," />
</head>

9
apps/web/nginx.conf Normal file
View file

@ -0,0 +1,9 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

View file

@ -1,5 +1,5 @@
{
"name": "@glossa/web",
"name": "@lila/web",
"private": true,
"version": "0.0.0",
"type": "module",
@ -9,10 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@glossa/shared": "workspace:*",
"@lila/shared": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-router": "^1.168.1",
"@tanstack/react-router-devtools": "^1.166.10",
"better-auth": "^1.6.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.2.2"

View file

@ -4,8 +4,8 @@ import {
SUPPORTED_POS,
DIFFICULTY_LEVELS,
GAME_ROUNDS,
} from "@glossa/shared";
import type { GameRequest } from "@glossa/shared";
} from "@lila/shared";
import type { GameRequest } from "@lila/shared";
const LABELS: Record<string, string> = {
en: "English",
@ -92,7 +92,7 @@ export const GameSetup = ({ onStart }: GameSetupProps) => {
return (
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
<h1 className="text-3xl font-bold text-purple-900 mb-1">Glossa</h1>
<h1 className="text-3xl font-bold text-purple-900 mb-1">lila</h1>
<p className="text-sm text-gray-400">Set up your quiz</p>
</div>

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import type { GameQuestion, AnswerResult } from "@glossa/shared";
import type { GameQuestion, AnswerResult } from "@lila/shared";
import { OptionButton } from "./OptionButton";
type QuestionCardProps = {

View file

@ -1,4 +1,4 @@
import type { AnswerResult } from "@glossa/shared";
import type { AnswerResult } from "@lila/shared";
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };

View file

@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: import.meta.env["VITE_API_URL"] || "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;

View file

@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
@ -18,6 +19,11 @@ const PlayRoute = PlayRouteImport.update({
path: '/play',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
@ -32,30 +38,34 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/play'
fullPaths: '/' | '/about' | '/login' | '/play'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/play'
id: '__root__' | '/' | '/about' | '/play'
to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/login' | '/play'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
PlayRoute: typeof PlayRoute
}
@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
PlayRoute: PlayRoute,
}
export const routeTree = rootRouteImport

View file

@ -1,20 +1,51 @@
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
Link,
Outlet,
useNavigate,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { useSession, signOut } from "../lib/auth-client";
const RootLayout = () => (
<>
<div className="p-2 flex gap-2">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>{" "}
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
</div>
<hr />
<Outlet />
<TanStackRouterDevtools />
</>
);
const RootLayout = () => {
const { data: session } = useSession();
const navigate = useNavigate();
return (
<>
<div className="p-2 flex gap-2 items-center">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
<div className="ml-auto">
{session ? (
<button
className="text-sm text-gray-600 hover:text-gray-900"
onClick={async () => {
await signOut();
navigate({ to: "/" });
}}
>
Sign out ({session.user.name})
</button>
) : (
<Link
to="/login"
className="text-sm text-blue-600 hover:text-blue-800"
>
Sign in
</Link>
)}
</div>
</div>
<hr />
<Outlet />
<TanStackRouterDevtools />
</>
);
};
export const Route = createRootRoute({ component: RootLayout });

View file

@ -0,0 +1,44 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { signIn, useSession } from "../lib/auth-client";
const LoginPage = () => {
const { data: session, isPending } = useSession();
const navigate = useNavigate();
if (isPending) return <div className="p-4">Loading...</div>;
if (session) {
navigate({ to: "/" });
return null;
}
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">sign in to lila</h1>
<button
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
onClick={() =>
signIn.social({
provider: "github",
callbackURL: window.location.origin,
})
}
>
Continue with GitHub
</button>
<button
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
onClick={() =>
signIn.social({
provider: "google",
callbackURL: window.location.origin,
})
}
>
Continue with Google
</button>
</div>
);
};
export const Route = createFileRoute("/login")({ component: LoginPage });

View file

@ -1,11 +1,14 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState, useCallback } from "react";
import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared";
import type { GameSession, GameRequest, AnswerResult } from "@lila/shared";
import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
function Play() {
const API_URL = import.meta.env["VITE_API_URL"] || "";
const [gameSession, setGameSession] = useState<GameSession | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
@ -14,9 +17,10 @@ function Play() {
const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true);
const response = await fetch("/api/v1/game/start", {
const response = await fetch(`${API_URL}/api/v1/game/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(settings),
});
const data = await response.json();
@ -41,9 +45,10 @@ function Play() {
const question = gameSession.questions[currentQuestionIndex];
if (!question) return;
const response = await fetch("/api/v1/game/answer", {
const response = await fetch(`${API_URL}/api/v1/game/answer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sessionId: gameSession.sessionId,
questionId: question.questionId,
@ -105,4 +110,12 @@ function Play() {
);
}
export const Route = createFileRoute("/play")({ component: Play });
export const Route = createFileRoute("/play")({
component: Play,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
},
});

View file

@ -2,6 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",

91
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,91 @@
services:
caddy:
container_name: lila-caddy
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
depends_on:
api:
condition: service_healthy
networks:
- lila-network
api:
container_name: lila-api
build:
context: .
dockerfile: ./apps/api/Dockerfile
target: runner
env_file:
- .env
restart: unless-stopped
healthcheck:
test:
["CMD-SHELL", "wget -qO- http://localhost:3000/api/health || exit 1"]
interval: 5s
timeout: 3s
retries: 5
depends_on:
database:
condition: service_healthy
networks:
- lila-network
web:
container_name: lila-web
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: production
args:
VITE_API_URL: https://api.lilastudy.com
restart: unless-stopped
networks:
- lila-network
database:
container_name: lila-database
image: postgres:18.3-alpine3.23
env_file:
- .env
environment:
- PGDATA=/var/lib/postgresql/data
volumes:
- lila-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- lila-network
forgejo:
container_name: lila-forgejo
image: codeberg.org/forgejo/forgejo:11
volumes:
- forgejo-data:/data
environment:
- USER_UID=1000
- USER_GID=1000
ports:
- "2222:22"
restart: unless-stopped
networks:
- lila-network
networks:
lila-network:
volumes:
lila-db:
caddy_data:
caddy_config:
forgejo-data:

View file

@ -1,6 +1,6 @@
services:
database:
container_name: glossa-database
container_name: lila-database
image: postgres:18.3-alpine3.23
env_file:
- .env
@ -9,7 +9,7 @@ services:
ports:
- "5432:5432"
volumes:
- glossa-db:/var/lib/postgresql/data
- lila-db:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
@ -18,7 +18,7 @@ services:
retries: 5
valkey:
container_name: glossa-valkey
container_name: lila-valkey
image: valkey/valkey:9.1-alpine3.23
ports:
- "6379:6379"
@ -30,7 +30,7 @@ services:
retries: 5
api:
container_name: glossa-api
container_name: lila-api
build:
context: .
dockerfile: ./apps/api/Dockerfile
@ -57,7 +57,7 @@ services:
condition: service_healthy
web:
container_name: glossa-web
container_name: lila-web
build:
context: .
dockerfile: ./apps/web/Dockerfile
@ -75,4 +75,4 @@ services:
condition: service_healthy
volumes:
glossa-db:
lila-db:

View file

@ -1,322 +0,0 @@
# Glossa — Architecture & API Development Summary
A record of all architectural discussions, decisions, and outcomes from the initial
API design through the quiz model implementation.
---
## Project Overview
Glossa is a vocabulary trainer (Duolingo-style) built as a pnpm monorepo. Users see a
word and pick from 4 possible translations. Supports singleplayer and multiplayer.
Stack: Express API, React frontend, Drizzle ORM, Postgres, Valkey, WebSockets.
---
## Architectural Foundation
### The Layered Architecture
The core mental model established for the entire API:
```text
HTTP Request
Router — maps URL + HTTP method to a controller
Controller — handles HTTP only: validates input, calls service, sends response
Service — business logic only: no HTTP, no direct DB access
Model — database queries only: no business logic
Database
```
**The rule:** each layer only talks to the layer directly below it. A controller never
touches the database. A service never reads `req.body`. A model never knows what a quiz is.
### Monorepo Package Responsibilities
| Package | Owns |
| ----------------- | -------------------------------------------------------- |
| `packages/shared` | Zod schemas, constants, derived TypeScript types |
| `packages/db` | Drizzle schema, DB connection, all model/query functions |
| `apps/api` | Router, controllers, services |
| `apps/web` | React frontend, consumes types from shared |
**Key principle:** all database code lives in `packages/db`. `apps/api` never imports
`drizzle-orm` for queries — it only calls functions exported from `packages/db`.
---
## Problems Faced & Solutions
- Problem 1: Messy API structure
**Symptom:** responsibilities bleeding across layers — DB code in controllers, business
logic in routes.
**Solution:** strict layered architecture with one responsibility per layer.
- Problem 2: No shared contract between API and frontend
**Symptom:** API could return different shapes silently, frontend breaks at runtime.
**Solution:** Zod schemas in `packages/shared` as the single source of truth. Both API
(validation) and frontend (type inference) consume the same schemas.
- Problem 3: Type safety gaps
**Symptom:** TypeScript `any` types on model parameters, `Number` vs `number` confusion.
**Solution:** derived types from constants using `typeof CONSTANT[number]` pattern.
All valid values defined once in constants, types derived automatically.
- Problem 4: `getGameTerms` in wrong package
**Symptom:** model queries living in `apps/api/src/models/` meant `apps/api` had a
direct `drizzle-orm` dependency and was accessing the DB itself.
**Solution:** moved models folder to `packages/db/src/models/`. All Drizzle code now
lives in one package.
- Problem 5: Deck generation complexity
**Initial assumption:** 12 decks needed (nouns/verbs × easy/intermediate/hard × en/it).
**Correction:** decks are pools, not presets. POS and difficulty are query filters applied
at runtime — not deck properties. Only 2 decks needed (en-core, it-core).
**Final decision:** skip deck generation entirely for MVP. Query the terms table directly
with difficulty + POS filters. Revisit post-MVP when spaced repetition or progression
features require curated pools.
- Problem 6: GAME_ROUNDS type conflict
**Problem:** `z.enum()` only accepts strings. `GAME_ROUNDS = ["3", "10"]` works with
`z.enum()` but requires `Number(rounds)` conversion in the service.
**Decision:** keep as strings, convert to number in the service before passing to the
model. Documented coupling acknowledged with a comment.
- Problem 7: Gloss join could multiply question rows. Schema allowed multiple glosses per term per language, so the left join would duplicate rows. Fixed by tightening the unique constraint.
- Problem 8: Model leaked quiz semantics. Return fields were named prompt / answer, baking HTTP-layer concepts into the database layer. Renamed to neutral field names.
- Problem 9: AnswerResult wasn't self-contained. Frontend needed selectedOptionId to render feedback but the schema didn't include it (reasoning was "client already knows"). Discovered during frontend work; added the field.
- Problem 10: Distractor could duplicate the correct answer text. Different terms can share the same translation. Fixed with ne(translations.text, excludeText) in the query.
- Problem 11: TypeScript strict mode flagged Fisher-Yates shuffle array access. noUncheckedIndexedAccess treats result[i] as T | undefined. Fixed with non-null assertion and temp variable pattern.
---
## Decisions Made
- Zod schemas belong in `packages/shared`
Both the API and frontend import from the same schemas. If the shape changes, TypeScript
compilation fails in both places simultaneously — silent drift is impossible.
- Server-side answer evaluation
The correct answer is never sent to the frontend in `QuizQuestion`. It is only revealed
in `AnswerResult` after the client submits. Prevents cheating and keeps game logic
authoritative on the server.
- `safeParse` over `parse` in controllers
`parse` throws a raw Zod error → ugly 500 response. `safeParse` returns a result object
→ clean 400 with early return. Global error handler to be implemented later (Step 6 of
roadmap) will centralise this pattern.
- POST not GET for game start
`GET` requests have no body. Game configuration is submitted as a JSON body → `POST` is
semantically correct.
- `express.json()` middleware required
Without it, `req.body` is `undefined`. Added to `createApp()` in `app.ts`.
- Type naming: PascalCase
TypeScript convention. `supportedLanguageCode``SupportedLanguageCode` etc.
- Primitive types: always lowercase
`number` not `Number`, `string` not `String`. The uppercase versions are object wrappers
and not assignable to Drizzle's expected primitive types.
- Model parameters use shared types, not `GameRequestType`
The model layer should not know about `GameRequestType` — that's an HTTP boundary concern.
Instead, parameters are typed using the derived constant types (`SupportedLanguageCode`,
`SupportedPos`, `DifficultyLevel`) exported from `packages/shared`.
- One gloss per term per language. The unique constraint on term_glosses was tightened from (term_id, language_code, text) to (term_id, language_code) to prevent the left join from multiplying question rows. Revisit if multiple glosses per language are ever needed (e.g. register or domain variants).
- Model returns neutral field names, not quiz semantics. getGameTerms returns sourceText / targetText / sourceGloss rather than prompt / answer / gloss. Quiz semantics are applied in the service layer. Keeps the model reusable for non-quiz features.
- Asymmetric difficulty filter. Difficulty is filtered on the target (answer) side only. A word can be A2 in Italian but B1 in English, and what matters is the difficulty of the word being learned.
- optionId as integer 0-3, not UUID. Options only need uniqueness within a single question; cheating prevented by shuffling, not opaque IDs.
- questionId and sessionId as UUIDs. Globally unique, opaque, natural Valkey keys when storage moves later.
- gloss is string | null rather than optional, for predictable shape on the frontend.
- GameSessionStore stores only the answer key (questionId → correctOptionId). Minimal payload for easy Valkey migration.
- All GameSessionStore methods are async even for the in-memory implementation, so the service layer is already written for Valkey.
- Distractors fetched per-question (N+1 queries). Correct shape for the problem; 10 queries on local Postgres is negligible latency.
- No fallback logic for insufficient distractors. Data volumes are sufficient; strict query throws if something is genuinely broken.
- Distractor query excludes both the correct term ID and the correct answer text, preventing duplicate options from different terms with the same translation.
- Submit-before-send flow on frontend: user selects, then confirms. Prevents misclicks.
---
## Data Pipeline Work (Pre-API)
### CEFR Enrichment Pipeline (completed)
A staged ETL pipeline was built to enrich translation records with CEFR levels and
difficulty ratings:
```text
Raw source files
extract-*.py — normalise each source to standard JSON
compare-*.py — quality gate: surface conflicts between sources (read-only)
merge-*.py — resolve conflicts by source priority, derive difficulty
enrich.ts — write cefr_level + difficulty to DB translations table
```
**Source priority:**
- English: `en_m3` > `cefrj` > `octanove` > `random`
- Italian: `it_m3` > `italian`
**Enrichment results:**
| Language | Enriched | Total | Coverage |
| -------- | -------- | ------- | -------- |
| English | 42,527 | 171,394 | ~25% |
| Italian | 23,061 | 54,603 | ~42% |
Both languages have sufficient coverage for MVP. Italian C2 has only 242 terms — noted
as a potential constraint for the distractor algorithm at high difficulty.
---
## API Schemas (packages/shared)
### `GameRequestSchema`
```typescript
{
source_language: z.enum(SUPPORTED_LANGUAGE_CODES),
target_language: z.enum(SUPPORTED_LANGUAGE_CODES),
pos: z.enum(SUPPORTED_POS),
difficulty: z.enum(DIFFICULTY_LEVELS),
rounds: z.enum(GAME_ROUNDS),
}
```
AnswerOption: { optionId: number (0-3), text: string }
GameQuestion: { questionId: uuid, prompt: string, gloss: string | null, options: AnswerOption[4] }
GameSession: { sessionId: uuid, questions: GameQuestion[] }
AnswerSubmission: { sessionId: uuid, questionId: uuid, selectedOptionId: number (0-3) }
AnswerResult: { questionId: uuid, isCorrect: boolean, correctOptionId: number (0-3), selectedOptionId: number (0-3) }
---
## API Endpoints
```text
POST /api/v1/game/start GameRequest → QuizQuestion[]
POST /api/v1/game/answer AnswerSubmission → AnswerResult
```
---
## Current File Structure (apps/api)
```text
apps/api/src/
├── app.ts — Express app, express.json() middleware
├── server.ts — starts server on PORT
├── routes/
│ ├── apiRouter.ts — mounts /health and /game routers
│ ├── gameRouter.ts — POST /start → createGame controller
│ └── healthRouter.ts
├── controllers/
│ └── gameController.ts — validates GameRequest, calls service
└── services/
└── gameService.ts — calls getGameTerms, returns raw rows
```
---
## Current File Structure (packages/db)
```text
packages/db/src/
├── db/
│ └── schema.ts — Drizzle schema (terms, translations, users, decks...)
├── models/
│ └── termModel.ts — getGameTerms() query
└── index.ts — exports db connection + getGameTerms
```
---
## Completed Tasks
- [x] Layered architecture established and understood
- [x] `GameRequestSchema` defined in `packages/shared`
- [x] Derived types (`SupportedLanguageCode`, `SupportedPos`, `DifficultyLevel`) exported from constants
- [x] `getGameTerms()` model implemented with POS / language / difficulty / limit filters
- [x] Model correctly placed in `packages/db`
- [x] `prepareGameQuestions()` service skeleton calling the model
- [x] `createGame` controller with Zod `safeParse` validation
- [x] `POST /api/v1/game/start` route wired
- [x] End-to-end pipeline verified with test script — returns correct rows
- [x] CEFR enrichment pipeline complete for English and Italian
- [x] Double join on translations implemented (source + target language)
- [x] Gloss left join implemented
- [x] Model return type uses neutral field names (sourceText, targetText, sourceGloss)
- [x] Schema: gloss unique constraint tightened to one gloss per term per language
- [x] Zod schemas defined: AnswerOption, GameQuestion, GameSession, AnswerSubmission, AnswerResult
- [x] getDistractors model implemented with POS/difficulty/language/excludeTermId/excludeText filters
- [x] createGameSession service: fetches terms, fetches distractors per question, shuffles options, stores session, returns GameSession
- [x] evaluateAnswer service: looks up session, compares submitted optionId to stored correct answer, returns AnswerResult
- [x] GameSessionStore interface + InMemoryGameSessionStore (Map-backed, swappable to Valkey)
- [x] POST /api/v1/game/answer endpoint wired (route, controller, service)
- [x] selectedOptionId added to AnswerResult (discovered during frontend work)
- [x] Minimal frontend: /play route with settings UI, QuestionCard, OptionButton, ScoreScreen
- [x] Vite proxy configured for dev
---
## Roadmap Ahead
### Step 1 — Learn SQL fundamentals - done
Concepts needed: SELECT, FROM, JOIN, WHERE, LIMIT.
Resources: sqlzoo.net or Khan Academy SQL section.
Required before: implementing the double join for source language prompt.
### Step 2 — Complete the model layer - done
- Double join on `translations` — once for source language (prompt), once for target language (answer)
- `GlossModel.getGloss(termId, languageCode)` — fetch gloss if available
### Step 3 — Define remaining Zod schemas - done
- `QuizQuestion`, `QuizOption`, `AnswerSubmission`, `AnswerResult` in `packages/shared`
### Step 4 — Complete the service layer - done
- `QuizService.buildSession()` — assemble raw rows into `QuizQuestion[]`
- Generate `questionId` per question
- Map source language translation as prompt
- Attach gloss if available
- Fetch 3 distractors (same POS, different term, same difficulty)
- Shuffle options so correct answer is not always in same position
- `QuizService.evaluateAnswer()` — validate correctness, return `AnswerResult`
### Step 5 — Implement answer endpoint - done
- `POST /api/v1/game/answer` route, controller, service method
### Step 6 — Global error handler
- Typed error classes (`ValidationError`, `NotFoundError`)
- Central error middleware in `app.ts`
- Remove temporary `safeParse` error handling from controllers
### Step 7 — Tests
- Unit tests for `QuizService` — correct POS filtering, distractor never equals correct answer
- Unit tests for `evaluateAnswer` — correct and incorrect cases
- Integration tests for both endpoints
### Step 8 — Auth (Phase 2 from original roadmap)
- OpenAuth integration
- JWT validation middleware
- `GET /api/auth/me` endpoint
- Frontend auth guard
---
## Open Questions
- **Distractor algorithm:** when Italian C2 has only 242 terms, should the difficulty
filter fall back gracefully or return an error? Decision needed before implementing
`buildSession()`. => resolved
- **Session statefulness:** game loop is currently stateless (fetch all questions upfront).
Confirm this is still the intended MVP approach before building `buildSession()`. => resolved
- **Glosses can leak answers:** some WordNet glosses contain the target-language
word in the definition text (e.g. "Padre" appearing in the English gloss for
"father"). Address during the post-MVP data enrichment pass — either clean the
glosses, replace them with custom definitions, or filter at the service layer. => resolved

View file

@ -1,351 +0,0 @@
# WordNet Seeding Script — Session Summary
## Project Context
A multiplayer EnglishItalian vocabulary trainer (Glossa) built with a pnpm monorepo. Vocabulary data comes from Open Multilingual Wordnet (OMW) and is extracted into JSON files, then seeded into a PostgreSQL database via Drizzle ORM.
---
## 1. JSON Extraction Format
Each synset extracted from WordNet is represented as:
```json
{
"synset_id": "ili:i35545",
"pos": "noun",
"translations": { "en": ["entity"], "it": ["cosa", "entità"] }
}
```
**Fields:**
- `synset_id` — OMW Interlingual Index ID, maps to `terms.synset_id` in the DB
- `pos` — part of speech, matches the CHECK constraint on `terms.pos`
- `translations` — object of language code → array of lemmas (synonyms within a synset)
**Glosses** are not extracted — the `term_glosses` table exists in the schema for future use but is not needed for the MVP quiz mechanic.
---
## 2. Database Schema (relevant tables)
```text
terms
id uuid PK
synset_id text UNIQUE
pos varchar(20)
created_at timestamptz
translations
id uuid PK
term_id uuid FK → terms.id (CASCADE)
language_code varchar(10)
text text
created_at timestamptz
UNIQUE (term_id, language_code, text)
```
---
## 3. Seeding Script — v1 (batch, truncate-based)
### Approach
- Read a single JSON file
- Batch inserts into `terms` and `translations` in groups of 500
- Truncate tables before each run for a clean slate
### Key decisions made during development
| Issue | Resolution |
| -------------------------------- | --------------------------------------------------- |
| `JSON.parse` returns `any` | Added `Array.isArray` check before casting |
| `forEach` doesn't await | Switched to `for...of` |
| Empty array types | Used Drizzle's `$inferInsert` types |
| `translations` naming conflict | Renamed local variable to `translationRows` |
| Final batch not flushed | Added `if (termsArray.length > 0)` guard after loop |
| Exact batch size check `=== 500` | Changed to `>= 500` |
### Final script structure
```ts
import fs from "node:fs/promises";
import { SUPPORTED_LANGUAGE_CODES, SUPPORTED_POS } from "@glossa/shared";
import { db } from "@glossa/db";
import { terms, translations } from "@glossa/db/schema";
type POS = (typeof SUPPORTED_POS)[number];
type LANGUAGE_CODE = (typeof SUPPORTED_LANGUAGE_CODES)[number];
type TermInsert = typeof terms.$inferInsert;
type TranslationInsert = typeof translations.$inferInsert;
type Synset = {
synset_id: string;
pos: POS;
translations: Record<LANGUAGE_CODE, string[]>;
};
const dataDir = "../../scripts/datafiles/";
const readFromJsonFile = async (filepath: string): Promise<Synset[]> => {
const data = await fs.readFile(filepath, "utf8");
const parsed = JSON.parse(data);
if (!Array.isArray(parsed)) throw new Error("Expected a JSON array");
return parsed as Synset[];
};
const uploadToDB = async (
termsData: TermInsert[],
translationsData: TranslationInsert[],
) => {
await db.insert(terms).values(termsData);
await db.insert(translations).values(translationsData);
};
const main = async () => {
console.log("Reading JSON file...");
const allSynsets = await readFromJsonFile(dataDir + "en-it-nouns.json");
console.log(`Loaded ${allSynsets.length} synsets`);
const termsArray: TermInsert[] = [];
const translationsArray: TranslationInsert[] = [];
let batchCount = 0;
for (const synset of allSynsets) {
const term = {
id: crypto.randomUUID(),
synset_id: synset.synset_id,
pos: synset.pos,
};
const translationRows = Object.entries(synset.translations).flatMap(
([lang, lemmas]) =>
lemmas.map((lemma) => ({
id: crypto.randomUUID(),
term_id: term.id,
language_code: lang as LANGUAGE_CODE,
text: lemma,
})),
);
translationsArray.push(...translationRows);
termsArray.push(term);
if (termsArray.length >= 500) {
batchCount++;
console.log(
`Uploading batch ${batchCount} (${batchCount * 500}/${allSynsets.length} synsets)...`,
);
await uploadToDB(termsArray, translationsArray);
termsArray.length = 0;
translationsArray.length = 0;
}
}
if (termsArray.length > 0) {
batchCount++;
console.log(
`Uploading final batch (${allSynsets.length}/${allSynsets.length} synsets)...`,
);
await uploadToDB(termsArray, translationsArray);
}
console.log(`Seeding complete — ${allSynsets.length} synsets inserted`);
};
main().catch((error) => {
console.error(error);
process.exit(1);
});
```
---
## 4. Pitfalls Encountered
### Duplicate key on re-run
Running the script twice causes `duplicate key value violates unique constraint "terms_synset_id_unique"`. Fix: truncate before seeding.
```bash
docker exec -it glossa-database psql -U glossa -d glossa -c "TRUNCATE translations, terms CASCADE;"
```
### `onConflictDoNothing` breaks FK references
When `onConflictDoNothing` skips a `terms` insert, the in-memory UUID is never written to the DB. Subsequent `translations` inserts reference that non-existent UUID, causing a FK violation. This is why the truncate approach is correct for batch seeding.
### DATABASE_URL misconfigured
Correct format:
```text
DATABASE_URL=postgresql://glossa:glossa@localhost:5432/glossa
```
### Tables not found after `docker compose up`
Migrations must be applied first: `npx drizzle-kit migrate`
---
## 5. Running the Script
```bash
# Start the DB container
docker compose up -d postgres
# Apply migrations
npx drizzle-kit migrate
# Truncate existing data (if re-seeding)
docker exec -it glossa-database psql -U glossa -d glossa -c "TRUNCATE translations, terms CASCADE;"
# Run the seed script
npx tsx src/seed-en-it-nouns.ts
# Verify
docker exec -it glossa-database psql -U glossa -d glossa -c "SELECT COUNT(*) FROM terms; SELECT COUNT(*) FROM translations;"
```
---
## 6. Seeding Script — v2 (incremental upsert, multi-file)
### Motivation
The truncate approach is fine for dev but unsuitable for production — it wipes all data. The v2 approach extends the database incrementally without ever truncating.
### File naming convention
One JSON file per language pair per POS:
```text
scripts/datafiles/
en-it-nouns.json
en-fr-nouns.json
en-it-verbs.json
de-it-nouns.json
...
```
### How incremental upsert works
For a concept like "dog" already in the DB with English and Italian:
1. Import `en-fr-nouns.json`
2. Upsert `terms` by `synset_id` — finds existing row, returns its real ID
3. `dog (en)` already exists → skipped by `onConflictDoNothing`
4. `chien (fr)` is new → inserted
The concept is **extended**, not replaced.
### Tradeoff vs batch approach
Batching is no longer possible since you need the real `term.id` from the DB before inserting translations. Each synset is processed individually. For 25k rows this is still fast enough.
### Key types added
```ts
type Synset = {
synset_id: string;
pos: POS;
translations: Partial<Record<LANGUAGE_CODE, string[]>>; // Partial — file only contains subset of languages
};
type FileName = {
sourceLang: LANGUAGE_CODE;
targetLang: LANGUAGE_CODE;
pos: POS;
};
```
### Filename validation
```ts
const parseFilename = (filename: string): FileName => {
const parts = filename.replace(".json", "").split("-");
if (parts.length !== 3)
throw new Error(
`Invalid filename format: ${filename}. Expected: sourcelang-targetlang-pos.json`,
);
const [sourceLang, targetLang, pos] = parts;
if (!SUPPORTED_LANGUAGE_CODES.includes(sourceLang as LANGUAGE_CODE))
throw new Error(`Unsupported language code: ${sourceLang}`);
if (!SUPPORTED_LANGUAGE_CODES.includes(targetLang as LANGUAGE_CODE))
throw new Error(`Unsupported language code: ${targetLang}`);
if (!SUPPORTED_POS.includes(pos as POS))
throw new Error(`Unsupported POS: ${pos}`);
return {
sourceLang: sourceLang as LANGUAGE_CODE,
targetLang: targetLang as LANGUAGE_CODE,
pos: pos as POS,
};
};
```
### Upsert function (WIP)
```ts
const upsertSynset = async (
synset: Synset,
fileInfo: FileName,
): Promise<{ termInserted: boolean; translationsInserted: number }> => {
const [upsertedTerm] = await db
.insert(terms)
.values({ synset_id: synset.synset_id, pos: synset.pos })
.onConflictDoUpdate({ target: terms.synset_id, set: { pos: synset.pos } })
.returning({ id: terms.id, created_at: terms.created_at });
const termInserted = upsertedTerm.created_at > new Date(Date.now() - 1000);
const translationRows = Object.entries(synset.translations).flatMap(
([lang, lemmas]) =>
lemmas!.map((lemma) => ({
id: crypto.randomUUID(),
term_id: upsertedTerm.id,
language_code: lang as LANGUAGE_CODE,
text: lemma,
})),
);
const result = await db
.insert(translations)
.values(translationRows)
.onConflictDoNothing()
.returning({ id: translations.id });
return { termInserted, translationsInserted: result.length };
};
```
---
## 7. Strategy Comparison
| Strategy | Use case | Pros | Cons |
| ------------------ | ----------------------------- | --------------------- | -------------------- |
| Truncate + batch | Dev / first-time setup | Fast, simple | Wipes all data |
| Incremental upsert | Production / adding languages | Safe, non-destructive | No batching, slower |
| Migrations-as-data | Production audit trail | Clean history | Files accumulate |
| Diff-based sync | Large production datasets | Minimal writes | Complex to implement |
---
## 8. packages/db — package.json exports fix
The `exports` field must be an object, not an array:
```json
"exports": {
".": "./src/index.ts",
"./schema": "./src/db/schema.ts"
}
```
Imports then resolve as:
```ts
import { db } from "@glossa/db";
import { terms, translations } from "@glossa/db/schema";
```

View file

@ -1,6 +1,6 @@
# Decisions Log
A record of non-obvious technical decisions made during development, with reasoning. Intended to preserve context across sessions.
A record of non-obvious technical decisions made during development, with reasoning. Intended to preserve context across sessions. Grouped by topic area.
---
@ -22,9 +22,9 @@ Drizzle is lighter — no binary, no engine. Queries map closely to SQL. Migrati
For rooms of 24 players, Socket.io's room management, transport fallbacks, and reconnection abstractions are unnecessary overhead. The WS protocol is defined explicitly as a Zod discriminated union in `packages/shared`, giving the same type safety guarantees. Reconnection logic is deferred to Phase 7.
### Auth: OpenAuth (not rolling own JWT)
### Auth: Better Auth (not OpenAuth or Keycloak)
All auth delegated to OpenAuth service at `auth.yourdomain.com`. Providers: Google, GitHub. The API validates the JWT on every protected request. User rows are created or updated on first login via the `sub` claim as the primary key.
Better Auth embeds as middleware in the Express API — no separate auth service or Docker container. It connects to the existing PostgreSQL via the Drizzle adapter and manages its own tables (user, session, account, verification). Social providers (Google, GitHub) are configured in a single config object. Session validation is a function call within the same process, not a network request. OpenAuth was considered but requires a standalone service and leaves user management to you. Keycloak is too heavy for a single-app project.
---
@ -32,21 +32,11 @@ All auth delegated to OpenAuth service at `auth.yourdomain.com`. Providers: Goog
### Multi-stage builds for monorepo context
Both `apps/web` and `apps/api` use multi-stage Dockerfiles (`deps`, `dev`, `builder`, `runner`) because:
- The monorepo structure requires copying `pnpm-workspace.yaml`, root `package.json`, and cross-dependencies (`packages/shared`, `packages/db`) before installing
- `node_modules` paths differ between host and container due to workspace hoisting
- Stages allow caching `pnpm install` separately from source code changes
Both `apps/web` and `apps/api` use multi-stage Dockerfiles (`deps`, `dev`, `builder`, `runner`) because the monorepo structure requires copying `pnpm-workspace.yaml`, root `package.json`, and cross-dependencies before installing. Stages allow caching `pnpm install` separately from source code changes.
### Vite as dev server (not Nginx)
In development, `apps/web` uses `vite dev` directly, not Nginx. Reasons:
- Hot Module Replacement (HMR) requires Vite's WebSocket dev server
- Source maps and error overlay need direct Vite integration
- Nginx would add unnecessary proxy complexity for local dev
Production will use Nginx to serve static Vite build output.
In development, `apps/web` uses `vite dev` directly, not Nginx. HMR requires Vite's WebSocket dev server. Production will use Nginx to serve static Vite build output.
---
@ -54,41 +44,111 @@ Production will use Nginx to serve static Vite build output.
### Express app structure: factory function pattern
`app.ts` exports a `createApp()` factory function. `server.ts` imports it and calls `.listen()`. This allows tests to import the app directly without starting a server, keeping tests isolated and fast.
`app.ts` exports a `createApp()` factory function. `server.ts` imports it and calls `.listen()`. This allows tests to import the app directly without starting a server (used by supertest).
### Data model: `decks` separate from `terms` (not frequency_rank filtering)
### Zod schemas belong in `packages/shared`
**Original approach:** Store `frequency_rank` on `terms` table and filter by rank range for difficulty.
Both the API and frontend import from the same schemas. If the shape changes, TypeScript compilation fails in both places simultaneously — silent drift is impossible.
**Problem discovered:** WordNet/OMW frequency data is unreliable for language learning. Extraction produced results like:
### Server-side answer evaluation
- Rank 1: "In" → "indio" (chemical symbol: Indium)
- Rank 2: "Be" → "berillio" (chemical symbol: Beryllium)
- Rank 7: "He" → "elio" (chemical symbol: Helium)
The correct answer is never sent to the frontend in `GameQuestion`. It is only revealed in `AnswerResult` after the client submits. Prevents cheating and keeps game logic authoritative on the server.
These are technically "common" in WordNet (every element is a noun) but useless for vocabulary learning.
### `safeParse` over `parse` in controllers
**Decision:**
`parse` throws a raw Zod error → ugly 500 response. `safeParse` returns a result object → clean 400 with early return via the error handler.
- `terms` table stores ALL available OMW synsets (raw data, no frequency filtering)
- `decks` table stores curated learning lists (A1, A2, B1, "Most Common 1000", etc.)
- `deck_terms` junction table links terms to decks with position ordering
- `rooms.deck_id` specifies which vocabulary deck a game uses
### POST not GET for game start
**Benefits:**
`GET` requests have no body. Game configuration is submitted as a JSON body → `POST` is semantically correct.
- Curricula can come from external sources (CEFR lists, Oxford 3000, SUBTLEX)
- Bad data (chemical symbols, obscure words) excluded at deck level, not schema level
- Users can create custom decks later
- Multiple difficulty levels without schema changes
### Model parameters use shared types, not `GameRequestType`
The model layer should not know about `GameRequestType` — that's an HTTP boundary concern. Parameters are typed using the derived constant types (`SupportedLanguageCode`, `SupportedPos`, `DifficultyLevel`) exported from `packages/shared`.
### Model returns neutral field names, not quiz semantics
`getGameTerms` returns `sourceText` / `targetText` / `sourceGloss` rather than `prompt` / `answer` / `gloss`. Quiz semantics are applied in the service layer. Keeps the model reusable for non-quiz features.
### Asymmetric difficulty filter
Difficulty is filtered on the target (answer) side only. A word can be A2 in Italian but B1 in English, and what matters is the difficulty of the word being learned.
### optionId as integer 0-3, not UUID
Options only need uniqueness within a single question; cheating prevented by shuffling, not opaque IDs.
### questionId and sessionId as UUIDs
Globally unique, opaque, natural Valkey keys when storage moves later.
### gloss is `string | null` rather than optional
Predictable shape on the frontend — always present, sometimes null.
### GameSessionStore stores only the answer key
Minimal payload (`questionId → correctOptionId`) for easy Valkey migration. All methods are async even for the in-memory implementation, so the service layer is already written for Valkey.
### Distractors fetched per-question (N+1 queries)
Correct shape for the problem; 10 queries on local Postgres is negligible latency.
### No fallback logic for insufficient distractors
Data volumes are sufficient; strict query throws if something is genuinely broken.
### Distractor query excludes both term ID and answer text
Prevents duplicate options from different terms with the same translation.
### Submit-before-send flow on frontend
User selects, then confirms. Prevents misclicks.
### Multiplayer mechanic: simultaneous answers (not buzz-first)
All players see the same question at the same time and submit independently. The server waits for all answers or a 15-second timeout, then broadcasts the result. This keeps the experience Duolingo-like and symmetric. A buzz-first mechanic was considered and rejected.
All players see the same question at the same time and submit independently. The server waits for all answers or a 15-second timeout, then broadcasts the result. Keeps the experience symmetric.
### Room model: room codes (not matchmaking queue)
Players create rooms and share a human-readable code (e.g. `WOLF-42`) to invite friends. Auto-matchmaking via a queue is out of scope for MVP. Valkey is included in the stack and can support a queue in a future phase.
Players create rooms and share a human-readable code (e.g. `WOLF-42`). Auto-matchmaking deferred.
---
## Error Handling
### `AppError` base class over error code maps
A `statusCode` on the error itself means the middleware doesn't need a lookup table. New error types are self-contained — one class, one status code. `ValidationError` (400) and `NotFoundError` (404) extend `AppError`.
### `next(error)` over `res.status().json()` in controllers
Express requires explicit `next(error)` for async handlers — it does not catch async errors automatically. Centralises all error formatting in one middleware. Controllers stay clean: validate, call service, send response.
### Zod `.message` over `.issues[0]?.message`
Returns all validation failures at once, not just the first. Output is verbose (raw JSON string) — revisit formatting post-MVP if the frontend needs structured `{ field, message }[]` error objects.
### Where errors are thrown
`ValidationError` is thrown in the controller (the layer that runs `safeParse`). `NotFoundError` is thrown in the service (the layer that knows whether a session or question exists). The service doesn't know about HTTP — it throws a typed error, and the middleware maps it to a status code.
---
## Testing
### Mocked DB for unit tests (not test database)
Unit tests mock `@lila/db` via `vi.mock` — the real database is never touched. Tests run in milliseconds with no infrastructure dependency. Integration tests with a real test DB are deferred post-MVP.
### Co-located test files
`gameService.test.ts` lives next to `gameService.ts`, not in a separate `__tests__/` directory. Convention matches the `vitest` default and keeps related files together.
### supertest for endpoint tests
Uses `createApp()` factory directly — no server started. Tests the full HTTP layer (routing, middleware, error handler) with real request/response assertions.
---
@ -96,19 +156,31 @@ Players create rooms and share a human-readable code (e.g. `WOLF-42`) to invite
### Base config: no `lib`, `module`, or `moduleResolution`
These are intentionally omitted from `tsconfig.base.json` because different packages need different values — `apps/api` uses `NodeNext`, `apps/web` uses `ESNext`/`bundler` (Vite), and mixing them in the base caused errors. Each package declares its own.
Intentionally omitted from `tsconfig.base.json` because different packages need different values — `apps/api` uses `NodeNext`, `apps/web` uses `ESNext`/`bundler` (Vite). Each package declares its own.
### `outDir: "./dist"` per package
The base config originally had `outDir: "dist"` which resolved relative to the base file location, pointing to the root `dist` folder. Overridden in each package with `"./dist"` to ensure compiled output stays inside the package.
The base config originally had `outDir: "dist"` which resolved relative to the base file location, pointing to the root `dist` folder. Overridden in each package with `"./dist"`.
### `apps/web` tsconfig: deferred to Vite scaffold
The web tsconfig was left as a placeholder and filled in after `pnpm create vite` generated `tsconfig.json`, `tsconfig.app.json`, and `tsconfig.node.json`. The generated files were then trimmed to remove options already covered by the base.
Filled in after `pnpm create vite` generated tsconfig files. The generated files were trimmed to remove options already covered by the base.
### `rootDir: "."` on `apps/api`
Set explicitly to allow `vitest.config.ts` (which lives outside `src/`) to be included in the TypeScript program. Without it, TypeScript infers `rootDir` as `src/` and rejects any file outside that directory.
Set explicitly to allow `vitest.config.ts` (outside `src/`) to be included in the TypeScript program.
### Type naming: PascalCase
`supportedLanguageCode``SupportedLanguageCode`. TypeScript convention.
### Primitive types: always lowercase
`number` not `Number`, `string` not `String`. The uppercase versions are object wrappers and not assignable to Drizzle's expected primitive types.
### `globals: true` with `"types": ["vitest/globals"]`
Using Vitest globals requires `"types": ["vitest/globals"]` in each package's tsconfig. Added to `apps/api`, `packages/shared`, `packages/db`, and `apps/web/tsconfig.app.json`.
---
@ -116,234 +188,147 @@ Set explicitly to allow `vitest.config.ts` (which lives outside `src/`) to be in
### Two-config approach for `apps/web`
The root `eslint.config.mjs` handles TypeScript linting across all packages. `apps/web/eslint.config.js` is kept as a local addition for React-specific plugins only: `eslint-plugin-react-hooks` and `eslint-plugin-react-refresh`. ESLint flat config merges them automatically by directory proximity — no explicit import between them needed.
Root `eslint.config.mjs` handles TypeScript linting across all packages. `apps/web/eslint.config.js` adds React-specific plugins only. ESLint flat config merges them by directory proximity.
### Coverage config at root only
Vitest coverage configuration lives in the root `vitest.config.ts` only. Individual package configs omit it to produce a single aggregated report rather than separate per-package reports.
### `globals: true` with `"types": ["vitest/globals"]`
Using Vitest globals (`describe`, `it`, `expect` without imports) requires `"types": ["vitest/globals"]` in each package's tsconfig `compilerOptions`. Added to `apps/api`, `packages/shared`, and `packages/db`. Added to `apps/web/tsconfig.app.json`.
---
## Known Issues / Dev Notes
### glossa-web has no healthcheck
The `web` service in `docker-compose.yml` has no `healthcheck` defined. Reason: Vite's dev server (`vite dev`) has no built-in health endpoint. Unlike the API's `/api/health`, there's no URL to poll.
Workaround: `depends_on` uses `api` healthcheck as proxy. For production (Nginx), add a health endpoint or use TCP port check.
### Valkey memory overcommit warning
Valkey logs this on start in development:
```text
WARNING Memory overcommit must be enabled for proper functionality
```
This is **harmless in dev** but should be fixed before production. The warning appears because Docker containers don't inherit host sysctl settings by default.
Fix: Add to host `/etc/sysctl.conf`:
```conf
vm.overcommit_memory = 1
```
Then `sudo sysctl -p` or restart Docker.
Vitest coverage configuration lives in the root `vitest.config.ts` only. Produces a single aggregated report.
---
## Data Model
### Users: internal UUID + openauth_sub (not sub as PK)
### Users: Better Auth manages the user table
**Original approach:** Use OpenAuth `sub` claim directly as `users.id` (text primary key).
**Problem:** Embeds auth provider in the primary key (e.g. `"google|12345"`). If OpenAuth changes format or a second provider is added, the PK cascades through all FKs (`rooms.host_id`, `room_players.user_id`).
**Decision:**
- `users.id` = internal UUID (stable FK target)
- `users.openauth_sub` = text UNIQUE (auth provider claim)
- Allows adding multiple auth providers per user later without FK changes
Better Auth creates and owns the user table (plus session, account, verification). The account table links social provider identities to users — one user can have both Google and GitHub linked. Other tables (rooms, stats) reference user.id via FK. No need to design a custom user schema or handle provider-specific claims manually.
### Rooms: `updated_at` for stale recovery only
Most tables omit `updated_at` (unnecessary for MVP). `rooms.updated_at` is kept specifically for stale room recovery—identifying rooms stuck in `in_progress` status after server crashes.
Most tables omit `updated_at`. `rooms.updated_at` is kept specifically for identifying rooms stuck in `in_progress` status after server crashes.
### Translations: UNIQUE (term_id, language_code, text)
Allows multiple synonyms per language per term (e.g. "dog", "hound" for same synset). Prevents exact duplicate rows. Homonyms (e.g. "Lead" metal vs. "Lead" guide) are handled by different `term_id` values (different synsets), so no constraint conflict.
Allows multiple synonyms per language per term (e.g. "dog", "hound" for same synset). Prevents exact duplicate rows.
### One gloss per term per language
The unique constraint on `term_glosses` was tightened from `(term_id, language_code, text)` to `(term_id, language_code)` to prevent left joins from multiplying question rows. Revisit if multiple glosses per language are ever needed.
### Decks: `source_language` + `validated_languages` (not `pair_id`)
**Original approach:** `decks.pair_id` references `language_pairs`, tying each deck to a single language pair.
**Problem:** One deck can serve multiple target languages as long as translations exist for all its terms. A `pair_id` FK would require duplicating the deck for each target language.
**Decision:**
- `decks.source_language` — the language the wordlist was curated from (e.g. `"en"`). A deck sourced from an English frequency list is fundamentally different from one sourced from an Italian list.
- `decks.validated_languages` — array of language codes (excluding `source_language`) for which full translation coverage exists across all terms in the deck. Recalculated and updated on every run of the generation script.
- The language pair used for a quiz session is determined at session start, not at deck creation time.
**Benefits:**
- One deck serves multiple target languages (e.g. en→it and en→fr) without duplication
- `validated_languages` stays accurate as translation data grows
- DB enforces via CHECK constraint that `source_language` is never included in `validated_languages`
One deck can serve multiple target languages as long as translations exist for all its terms. `source_language` is the language the wordlist was curated from. `validated_languages` is recalculated on every generation script run. Enforced via CHECK: `source_language` is never in `validated_languages`.
### Decks: wordlist tiers as scope (not POS-split decks)
**Rejected approach:** one deck per POS (e.g. `en-nouns`, `en-verbs`).
**Problem:** POS is already a filterable column on `terms`, so a POS-scoped deck duplicates logic the query already handles for free. A word like "run" (noun and verb, different synsets) would also appear in two decks, requiring deduplication in the generation script.
**Decision:** one deck per frequency tier per source language (e.g. `en-core-1000`, `en-core-2000`). POS, difficulty, and category are query filters applied inside that boundary at query time. The user never sees or picks a deck — they pick a direction, POS, and difficulty, and the app resolves those to the right deck + filters.
Progression works by expanding the deck set as the user advances:
```sql
WHERE dt.deck_id IN ('en-core-1000', 'en-core-2000')
AND t.pos = 'noun'
AND t.cefr_level = 'B1'
```
Decks must not overlap — each term appears in exactly one tier. The generation script already deduplicates, so this is enforced at import time.
One deck per frequency tier per source language (e.g. `en-core-1000`). POS, difficulty, and category are query filters applied inside that boundary. Decks must not overlap — each term appears in exactly one tier.
### Decks: SUBTLEX as wordlist source (not manual curation)
**Problem:** the most common 1000 nouns in English are not the same 1000 nouns that are most common in Italian — not just in translation, but conceptually. Building decks from English frequency data alone gives Italian learners a distorted picture of what is actually common in Italian.
**Decision:** use SUBTLEX, which exists in per-language editions (SUBTLEX-EN, SUBTLEX-IT, etc.) derived from subtitle corpora using the same methodology, making them comparable across languages.
This is why `decks.source_language` is not just a technical detail — it is the reason the data model is correct:
- `en-core-1000` built from SUBTLEX-EN → used when source language is English (en→it)
- `it-core-1000` built from SUBTLEX-IT → used when source language is Italian (it→en)
Same translation data underneath, correctly frequency-grounded per direction. Two wordlist files, two generation script runs.
### Terms: `synset_id` nullable (not NOT NULL)
**Problem:** non-WordNet terms (custom words, Wiktionary-sourced entries added later) won't have a synset ID. `NOT NULL` is too strict.
**Decision:** make `synset_id` nullable. `synset_id` remains the WordNet idempotency key — it prevents duplicate imports on re-runs and allows cross-referencing back to WordNet. It is not removed.
Postgres `UNIQUE` on a nullable column allows multiple `NULL` values (nulls are not considered equal), so no additional constraint logic is needed beyond dropping `notNull()`. For extra defensiveness a partial unique index can be added later:
```sql
CREATE UNIQUE INDEX idx_terms_synset_id ON terms (synset_id) WHERE synset_id IS NOT NULL;
```
### Terms: `source` + `source_id` columns
Once multiple import pipelines exist (OMW today, Wiktionary later), `synset_id` alone is insufficient as an idempotency key — Wiktionary terms won't have a synset ID.
**Decision:** add `source` (varchar, e.g. `'omw'`, `'wiktionary'`, null for manual) and `source_id` (text, the pipeline's internal identifier) with a unique constraint on the pair:
```ts
unique("unique_source_id").on(table.source, table.source_id);
```
Postgres allows multiple `NULL` pairs under a unique constraint, so manual entries don't conflict. For existing OMW terms, backfill `source = 'omw'` and `source_id = synset_id`. `synset_id` remains for now to avoid pipeline churn — deprecate it during a future pipeline refactor.
No CHECK constraint on `source` — it is only written by controlled import scripts, not user input. A free varchar is sufficient.
### Translations: `cefr_level` column (deferred population, not on `terms`)
CEFR difficulty is language-relative, not concept-relative. "House" in English is A1, "domicile" is also English but B2 — same concept, different words, different difficulty. Moving `cefr_level` to `translations` allows each language's word to have its own level independently.
Added as nullable `varchar(2)` with CHECK constraint against `CEFR_LEVELS` (`A1``C2`) on the `translations` table. Left null for MVP; populated later via SUBTLEX or an external CEFR wordlist. Also included in the `translations` index since the quiz query filters on it:
```ts
index("idx_translations_lang").on(
table.language_code,
table.cefr_level,
table.term_id,
);
```
The most common 1000 nouns in English are not the same 1000 nouns that are most common in Italian. SUBTLEX exists in per-language editions derived from subtitle corpora using the same methodology — making them comparable. `en-core-1000` built from SUBTLEX-EN, `it-core-1000` from SUBTLEX-IT.
### `language_pairs` table: dropped
Valid language pairs are already implicitly defined by `decks.source_language` + `decks.validated_languages`. The table was redundant — the same information can be derived directly from decks:
Valid pairs are implicitly defined by `decks.source_language` + `decks.validated_languages`. The table was redundant.
```sql
SELECT DISTINCT source_language, unnest(validated_languages) AS target_language
FROM decks
WHERE validated_languages != '{}'
```
### Terms: `synset_id` nullable (not NOT NULL)
The only thing `language_pairs` added was an `active` flag to manually disable a direction. This is an edge case not needed for MVP. Dropped to remove a maintenance surface that required staying in sync with deck data.
Non-WordNet terms won't have a synset ID. Postgres `UNIQUE` on a nullable column allows multiple NULL values.
### Schema: `categories` + `term_categories` (empty for MVP)
### Terms: `source` + `source_id` columns
Added to schema now, left empty for MVP. Grammar and Media work without them — Grammar maps to POS (already on `terms`), Media maps to deck membership. Thematic categories (animals, kitchen, etc.) require a metadata source that is still under research.
Once multiple import pipelines exist (OMW, Wiktionary), `synset_id` alone is insufficient as an idempotency key. Unique constraint on the pair. Postgres allows multiple NULL pairs. `synset_id` remains for now — deprecate during a future pipeline refactor.
```sql
categories: id, slug, label, created_at
term_categories: term_id → terms.id, category_id → categories.id, PK(term_id, category_id)
```
### `cefr_level` on `translations` (not `terms`)
See Open Research section for source options.
CEFR difficulty is language-relative, not concept-relative. "House" in English is A1, "domicile" is also English but B2 — same concept, different words, different difficulty. Added as nullable `varchar(2)` with CHECK.
### Schema constraints: CHECK over pgEnum for extensible value sets
### Categories + term_categories: empty for MVP
**Question:** use `pgEnum` for columns like `pos`, `cefr_level`, and `source` since the values are driven by TypeScript constants anyway?
Schema exists. Grammar maps to POS (already on `terms`), Media maps to deck membership. Thematic categories require a metadata source still under research.
**Decision:** no. Use CHECK constraints for any value set that will grow over time.
### CHECK over pgEnum for extensible value sets
**Reason:** `ALTER TYPE enum_name ADD VALUE` in Postgres is non-transactional — it cannot be rolled back if a migration fails partway through, leaving the DB in a dirty state that requires manual intervention. CHECK constraints are fully transactional — if the migration fails it rolls back cleanly.
`ALTER TYPE enum_name ADD VALUE` in Postgres is non-transactional — cannot be rolled back if a migration fails. CHECK constraints are fully transactional. Rule: pgEnum for truly static sets, CHECK for any set tied to a growing constant.
**Rule of thumb:** pgEnum is appropriate for truly static value sets that will never grow (e.g. `('pending', 'active', 'cancelled')` on an orders table). Any value set tied to a growing constant in the codebase (`SUPPORTED_POS`, `CEFR_LEVELS`, `SUPPORTED_LANGUAGE_CODES`) stays as a CHECK constraint.
### `language_code` always CHECK-constrained
### Schema constraints: `language_code` always CHECK-constrained
Unlike `source` (only written by import scripts), `language_code` is a query-critical filter column. A typo would silently produce missing data. Rule: any column game queries filter on should be CHECK-constrained.
`language_code` columns on `translations` and `term_glosses` are constrained via CHECK against `SUPPORTED_LANGUAGE_CODES`, the same pattern used for `pos` and `cefr_level`.
### Unique constraints make explicit FK indexes redundant
**Reason:** unlike `source`, which is only written by controlled import scripts and failing silently is recoverable, `language_code` is a query-critical filter column. A typo (`'ita'` instead of `'it'`, `'en '` with a trailing space) would silently produce missing data in the UI — terms with no translation shown, glosses not displayed — which is harder to debug than a DB constraint violation.
**Rule:** any column that game queries filter on should be CHECK-constrained. Columns only used for internal bookkeeping (like `source`) can be left as free varchars.
### Schema: unique constraints make explicit FK indexes redundant
Postgres automatically creates an index to enforce a unique constraint. An explicit index on a column that is already the leading column of a unique constraint is redundant.
Example: `unique("unique_term_gloss").on(term_id, language_code, text)` already indexes `term_id` as the leading column. A separate `index("idx_term_glosses_term").on(term_id)` adds no value and was dropped.
**Rule:** before adding an explicit index, check whether an existing unique constraint already covers it.
### Future extensions: morphology and pronunciation (deferred, additive)
The following features are explicitly deferred post-MVP. All are purely additive — new tables referencing existing `terms` rows via FK. No existing schema changes required when implemented:
- `noun_forms` — gender, singular, plural, articles per language (source: Wiktionary)
- `verb_forms` — conjugation tables per language (source: Wiktionary)
- `term_pronunciations` — IPA and audio URLs per language (source: Wiktionary / Forvo)
Exercise types split naturally into Type A (translation, current model) and Type B (morphology, future). The data layer is independent — the same `terms` anchor both.
Postgres automatically creates an index to enforce a unique constraint. A separate index on the leading column of an existing unique constraint adds no value.
---
### Term glosses: Italian coverage is sparse (expected)
## Data Pipeline
OMW gloss data is primarily in English. After full import:
### Seeding v1: batch, truncate-based
- English glosses: 95,882 (~100% of terms)
- Italian glosses: 1,964 (~2% of terms)
For dev/first-time setup. Read JSON, batch inserts in groups of 500, truncate tables before each run. Simple and fast.
This is not a data pipeline problem — it reflects the actual state of OMW. Italian
glosses simply don't exist for most synsets in the dataset.
Key pitfalls encountered:
**Handling in the UI:** fall back to the English gloss when no gloss exists for the
user's language. This is acceptable UX — a definition in the wrong language is better
than no definition at all.
- Duplicate key on re-run: truncate before seeding
- `onConflictDoNothing` breaks FK references: when it skips a `terms` insert, the in-memory UUID is never written, causing FK violations on `translations`
- `forEach` doesn't await: use `for...of`
- Final batch not flushed: guard with `if (termsArray.length > 0)` after loop
If Italian gloss coverage needs to improve in the future, Wiktionary is the most
likely source — it has broader multilingual definition coverage than OMW.
### Seeding v2: incremental upsert, multi-file
For production / adding languages. Extends the database without truncating. Each synset processed individually (no batching — need real `term.id` from DB before inserting translations). Filename convention: `sourcelang-targetlang-pos.json`.
### CEFR enrichment pipeline
Staged ETL: `extract-*.py``compare-*.py` (quality gate) → `merge-*.py` (resolve conflicts) → `enrich.ts` (write to DB). Source priority: English `en_m3 > cefrj > octanove > random`, Italian `it_m3 > italian`.
Enrichment results: English 42,527/171,394 (~25%), Italian 23,061/54,603 (~42%). Both sufficient for MVP. Italian C2 has only 242 terms — noted as constraint for distractor algorithm.
### Term glosses: Italian coverage is sparse
OMW gloss data is primarily English. English glosses: 95,882 (~100%), Italian: 1,964 (~2%). UI falls back to English gloss when no gloss exists for the user's language.
### Glosses can leak answers
Some WordNet glosses contain the target-language word in the definition text (e.g. "Padre" in the English gloss for "father"). Address during post-MVP data enrichment — clean glosses, replace with custom definitions, or filter at service layer.
### `packages/db` exports fix
The `exports` field must be an object, not an array:
```json
"exports": {
".": "./src/index.ts",
"./schema": "./src/db/schema.ts"
}
```
---
## API Development: Problems & Solutions
1. **Messy API structure.** Responsibilities bleeding across layers. Fixed with strict layered architecture.
2. **No shared contract.** API could return different shapes silently. Fixed with Zod schemas in `packages/shared`.
3. **Type safety gaps.** `any` types, `Number` vs `number`. Fixed with derived types from constants.
4. **`getGameTerms` in wrong package.** Model queries in `apps/api` meant direct `drizzle-orm` dependency. Moved to `packages/db/src/models/`.
5. **Deck generation complexity.** 12 decks assumed, only 2 needed. Then skipped entirely for MVP — query terms table directly.
6. **GAME_ROUNDS type conflict.** `z.enum()` only accepts strings. Keep as strings, convert to number in service.
7. **Gloss join multiplied rows.** Multiple glosses per term per language. Fixed by tightening unique constraint.
8. **Model leaked quiz semantics.** Return fields named `prompt`/`answer`. Renamed to neutral `sourceText`/`targetText`.
9. **AnswerResult wasn't self-contained.** Frontend needed `selectedOptionId` but schema didn't include it. Added.
10. **Distractor could duplicate correct answer.** Different terms with same translation. Fixed with `ne(translations.text, excludeText)`.
11. **TypeScript strict mode flagged Fisher-Yates shuffle.** `noUncheckedIndexedAccess` treats `result[i]` as `T | undefined`. Fixed with non-null assertion + temp variable.
---
## Known Issues / Dev Notes
### lila-web has no healthcheck
Vite's dev server has no built-in health endpoint. `depends_on` uses API healthcheck as proxy. For production (Nginx), add a health endpoint or TCP port check.
### Valkey memory overcommit warning
Harmless in dev. Fix before production: add `vm.overcommit_memory = 1` to host `/etc/sysctl.conf`.
---
@ -351,88 +336,26 @@ likely source — it has broader multilingual definition coverage than OMW.
### Semantic category metadata source
Categories (`animals`, `kitchen`, etc.) are in the schema but empty for MVP.
Grammar and Media work without them (Grammar = POS filter, Media = deck membership).
Needs research before populating `term_categories`. Options:
Categories (`animals`, `kitchen`, etc.) are in the schema but empty. Options researched:
**Option 1: WordNet domain labels**
Already in OMW, extractable in the existing pipeline. Free, no extra dependency.
Problem: coarse and patchy — many terms untagged, vocabulary is academic ("fauna" not "animals").
**Option 2: Princeton WordNet Domains**
Separate project built on WordNet. ~200 hierarchical domains mapped to synsets. More structured
and consistent than basic WordNet labels. Freely available. Meaningfully better than Option 1.
**Option 3: Kelly Project**
Frequency lists with CEFR levels AND semantic field tags, explicitly designed for language learning,
multiple languages. Could solve frequency tiers (`cefr_level`) and semantic categories in one shot.
Investigate coverage for your languages and POS range first.
**Option 4: BabelNet / WikiData**
Rich, multilingual, community-maintained. Maps WordNet synsets to Wikipedia categories.
Problem: complex integration, BabelNet has commercial licensing restrictions, WikiData category
trees are deep and noisy.
**Option 5: LLM-assisted categorization**
Run terms through Claude/GPT-4 with a fixed category list, spot-check output, import.
Fast and cheap at current term counts (3171 terms ≈ negligible cost). Not reproducible
without saving output. Good fallback if structured sources have insufficient coverage.
**Option 6: Hybrid — WordNet Domains as baseline, LLM gap-fill**
Use Option 2 for automated coverage, LLM for terms with no domain tag, manual spot-check pass.
Combines automation with control. Likely the most practical approach.
**Option 7: Manual curation**
Flat file mapping synset IDs to your own category slugs. Full control, matches UI exactly.
Too expensive at scale — only viable for small curated additions on top of an automated baseline.
1. **WordNet domain labels** — already in OMW, coarse and patchy
2. **Princeton WordNet Domains** — ~200 hierarchical domains, freely available, meaningfully better
3. **Kelly Project** — CEFR levels AND semantic fields, designed for language learning. Could solve frequency tiers and categories in one shot
4. **BabelNet / WikiData** — rich but complex integration, licensing issues
5. **LLM-assisted categorization** — fast and cheap at current term counts, not reproducible without saving output
6. **Hybrid (WordNet Domains + LLM gap-fill)** — likely most practical
7. **Manual curation** — full control, too expensive at scale
**Current recommendation:** research Kelly Project first. If coverage is insufficient, go with Option 6.
---
### SUBTLEX → `cefr_level` mapping strategy
## Current State
Raw frequency ranks need mapping to A1C2 bands before tiered decks are meaningful. Decision pending.
Phase 0 complete. Phase 1 data pipeline complete. Phase 2 data model finalized and migrated.
### Future extensions: morphology and pronunciation
### Completed (Phase 1 — data pipeline)
All deferred post-MVP, purely additive (new tables referencing existing `terms`):
- [x] Run `extract-en-it-nouns.py` locally → generates `datafiles/en-it-nouns.json`
- [x] Write Drizzle schema: `terms`, `translations`, `language_pairs`, `term_glosses`, `decks`, `deck_terms`
- [x] Write and run migration (includes CHECK constraints for `pos`, `gloss_type`)
- [x] Write `packages/db/src/seed.ts` (imports ALL terms + translations, NO decks)
- [x] Write `packages/db/src/generating-decks.ts` — idempotent deck generation script
- reads and deduplicates source wordlist
- matches words to DB terms (homonyms included)
- writes unmatched words to `-missing` file
- determines `validated_languages` by checking full translation coverage per language
- creates deck if it doesn't exist, adds only missing terms on subsequent runs
- recalculates and persists `validated_languages` on every run
### Completed (Phase 2 — data model)
- [x] `synset_id` removed, replaced by `source` + `source_id` on `terms`
- [x] `cefr_level` added to `translations` (not `terms` — difficulty is language-relative)
- [x] `language_code` CHECK constraint added to `translations` and `term_glosses`
- [x] `language_pairs` table dropped — pairs derived from decks at query time
- [x] `is_public` and `added_at` dropped from `decks` and `deck_terms`
- [x] `type` added to `decks` with CHECK against `SUPPORTED_DECK_TYPES`
- [x] `topics` and `term_topics` tables added (empty for MVP)
- [x] Migration generated and run against fresh database
### Known data facts (pre-wipe, for reference)
- Wordlist: 999 unique words after deduplication (1000 lines, 1 duplicate)
- Term IDs resolved: 3171 (higher than word count due to homonyms)
- Words not found in DB: 34
- Italian (`it`) coverage: 3171 / 3171 — full coverage, included in `validated_languages`
### Next (Phase 3 — data pipeline + API)
1. done
2. done
3. **Expand data pipeline** — import all OMW languages and POS, not just English nouns with Italian translations
4. **Decide SUBTLEX → `cefr_level` mapping strategy** — raw frequency ranks need a mapping to A1C2 bands before tiered decks are meaningful
5. **Generate decks** — run generation script with SUBTLEX-grounded wordlists per source language
6. **Finalize game selection flow** — direction → category → POS → difficulty → round count
7. **Define Zod schemas in `packages/shared`** — based on finalized game flow and API shape
8. **Implement API**
- `noun_forms` — gender, singular, plural, articles per language (source: Wiktionary)
- `verb_forms` — conjugation tables per language (source: Wiktionary)
- `term_pronunciations` — IPA and audio URLs per language (source: Wiktionary / Forvo)

View file

@ -1,469 +0,0 @@
# glossa mvp
> **This document is the single source of truth for the project.**
> It is written to be handed to any LLM as context. It contains the project vision, the current MVP scope, the tech stack, the working methodology, and the roadmap.
---
## 1. Project Overview
A vocabulary trainer for EnglishItalian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The long-term vision is a multiplayer competitive game, but the MVP is a polished singleplayer experience.
**The core learning loop:**
Show word → pick answer → see result → next word → final score
The vocabulary data comes from WordNet + the Open Multilingual Wordnet (OMW). A one-time Python script extracts EnglishItalian noun pairs and seeds the database. The data model is language-pair agnostic by design — adding a new language later requires no schema changes.
---
## 2. What the Full Product Looks Like (Long-Term Vision)
- Users log in via Google or GitHub (OpenAuth)
- Singleplayer mode: 10-round quiz, score screen
- Multiplayer mode: create a room, share a code, 24 players answer simultaneously in real time, live scores, winner screen
- 1000+ EnglishItalian nouns seeded from WordNet
This is documented in `spec.md` and the full `roadmap.md`. The MVP deliberately ignores most of it.
---
## 3. MVP Scope
**Goal:** A working, presentable singleplayer quiz that can be shown to real people.
### What is IN the MVP
- Vocabulary data in a PostgreSQL database (already seeded)
- REST API that returns quiz terms with distractors
- Singleplayer quiz UI: 10 questions, answer feedback, score screen
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
- Local dev only (no deployment for MVP)
### What is CUT from the MVP
| Feature | Why cut |
| ------------------------------- | -------------------------------------- |
| Authentication (OpenAuth) | No user accounts needed for a demo |
| Multiplayer (WebSockets, rooms) | Core quiz works without it |
| Valkey / Redis cache | Only needed for multiplayer room state |
| Deployment to Hetzner | Ship to people locally first |
| User stats / profiles | Needs auth |
| Testing suite | Add after the UI stabilises |
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 9 (Post-MVP Ladder).
---
## 4. Technology Stack
The monorepo structure and tooling are already set up (Phase 0 complete). This is the full stack — the MVP uses a subset of it.
| Layer | Technology | MVP? |
| ------------ | ------------------------------ | ----------- |
| Monorepo | pnpm workspaces | ✅ |
| Frontend | React 18, Vite, TypeScript | ✅ |
| Routing | TanStack Router | ✅ |
| Server state | TanStack Query | ✅ |
| Client state | Zustand | ✅ |
| Styling | Tailwind CSS + shadcn/ui | ✅ |
| Backend | Node.js, Express, TypeScript | ✅ |
| Database | PostgreSQL + Drizzle ORM | ✅ |
| Validation | Zod (shared schemas) | ✅ |
| Auth | OpenAuth (Google + GitHub) | ❌ post-MVP |
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
| Cache | Valkey | ❌ post-MVP |
| Testing | Vitest, React Testing Library | ❌ post-MVP |
| Deployment | Docker Compose, Hetzner, Nginx | ❌ post-MVP |
### Repository Structure (actual, as of Phase 1 data pipeline complete)
```text
vocab-trainer/
├── apps/
│ ├── api/
│ │ └── src/
│ │ ├── app.ts # createApp() factory — routes registered here
│ │ └── server.ts # calls app.listen()
│ └── web/
│ └── src/
│ ├── routes/
│ │ ├── __root.tsx
│ │ ├── index.tsx # placeholder landing page
│ │ └── about.tsx
│ ├── main.tsx
│ └── index.css
├── packages/
│ ├── shared/
│ │ └── src/
│ │ ├── index.ts # empty — Zod schemas go here next
│ │ └── constants.ts
│ └── db/
│ ├── drizzle/ # migration SQL files
│ └── src/
│ ├── db/schema.ts # full Drizzle schema
│ ├── seeding-datafiles.ts # seeds terms + translations
│ ├── generating-deck.ts # builds curated decks
│ └── index.ts
├── documentation/ # all project docs live here
│ ├── spec.md
│ ├── roadmap.md
│ ├── decisions.md
│ ├── mvp.md # this file
│ └── CLAUDE.md
├── scripts/
│ ├── extract-en-it-nouns.py
│ └── datafiles/en-it-noun.json
├── docker-compose.yml
└── pnpm-workspace.yaml
```
**What does not exist yet (to be built in MVP phases):**
- `apps/api/src/routes/` — no route handlers yet
- `apps/api/src/services/` — no business logic yet
- `apps/api/src/repositories/` — no DB queries yet
- `apps/web/src/components/` — no UI components yet
- `apps/web/src/stores/` — no Zustand store yet
- `apps/web/src/lib/api.ts` — no TanStack Query wrappers yet
- `packages/shared/src/schemas/` — no Zod schemas yet
`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas — never duplicated.
---
## 5. Data Model (relevant tables for MVP)
```javascript
export const terms = pgTable(
"terms",
{
id: uuid().primaryKey().defaultRandom(),
synset_id: text().unique().notNull(),
pos: varchar({ length: 20 }).notNull(),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
},
(table) => [
check(
"pos_check",
sql`${table.pos} IN (${sql.raw(SUPPORTED_POS.map((p) => `'${p}'`).join(", "))})`,
),
index("idx_terms_pos").on(table.pos),
],
);
export const translations = pgTable(
"translations",
{
id: uuid().primaryKey().defaultRandom(),
term_id: uuid()
.notNull()
.references(() => terms.id, { onDelete: "cascade" }),
language_code: varchar({ length: 10 }).notNull(),
text: text().notNull(),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
},
(table) => [
unique("unique_translations").on(
table.term_id,
table.language_code,
table.text,
),
index("idx_translations_lang").on(table.language_code, table.term_id),
],
);
export const decks = pgTable(
"decks",
{
id: uuid().primaryKey().defaultRandom(),
name: text().notNull(),
description: text(),
source_language: varchar({ length: 10 }).notNull(),
validated_languages: varchar({ length: 10 }).array().notNull().default([]),
is_public: boolean().default(false).notNull(),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
},
(table) => [
check(
"source_language_check",
sql`${table.source_language} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
),
check(
"validated_languages_check",
sql`validated_languages <@ ARRAY[${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))}]::varchar[]`,
),
check(
"validated_languages_excludes_source",
sql`NOT (${table.source_language} = ANY(${table.validated_languages}))`,
),
unique("unique_deck_name").on(table.name, table.source_language),
],
);
export const deck_terms = pgTable(
"deck_terms",
{
deck_id: uuid()
.notNull()
.references(() => decks.id, { onDelete: "cascade" }),
term_id: uuid()
.notNull()
.references(() => terms.id, { onDelete: "cascade" }),
added_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
},
(table) => [primaryKey({ columns: [table.deck_id, table.term_id] })],
);
```
The seed + deck-build scripts have already been run. Data exists in the database.
---
## 6. API Endpoints (MVP)
All endpoints prefixed `/api`. Schemas live in `packages/shared` and are validated with Zod on both sides.
| Method | Path | Description |
| ------ | ---------------------- | --------------------------------------- |
| GET | `/api/health` | Health check (already done) |
| GET | `/api/language-pairs` | List active language pairs |
| GET | `/api/decks` | List available decks |
| GET | `/api/decks/:id/terms` | Fetch terms with distractors for a quiz |
### Distractor Logic
The `QuizService` picks 3 distractors server-side:
- Same part-of-speech as the correct answer
- Never the correct answer
- Never repeated within a session
---
## 7. Frontend Structure (MVP)
```text
apps/web/src/
├── routes/
│ ├── index.tsx # Landing page / mode select
│ └── singleplayer/
│ └── index.tsx # The quiz
├── components/
│ ├── quiz/
│ │ ├── QuestionCard.tsx # Prompt word + 4 answer buttons
│ │ ├── OptionButton.tsx # idle / correct / wrong states
│ │ └── ScoreScreen.tsx # Final score + play again
│ └── ui/ # shadcn/ui wrappers
├── stores/
│ └── gameStore.ts # Zustand: question index, score, answers
└── lib/
└── api.ts # TanStack Query fetch wrappers
```
### State Management
TanStack Query handles fetching quiz data from the API. Zustand handles the local quiz session (current question index, score, selected answers). There is no overlap between the two.
---
## 8. Working Methodology
> **Read this section before asking for help with any task.**
This project is a learning exercise. The goal is to understand the code, not just to ship it.
### How tasks are structured
The roadmap (Section 10) lists broad phases. When work starts on a phase, it gets broken into smaller, concrete subtasks with clear done-conditions before any code is written.
### How to use an LLM for help
When asking an LLM for help:
1. **Paste this document** (or the relevant sections) as context
2. **Describe what you're working on** and what specifically you're stuck on
3. **Ask for hints, not solutions.** Example prompts:
- "I'm trying to implement X. My current approach is Y. What am I missing conceptually?"
- "Here is my code. What would you change about the structure and why?"
- "Can you point me to the relevant docs for Z?"
### Refactoring workflow
After completing a task or a block of work:
1. Share the current state of the code with the LLM
2. Ask: _"What would you refactor here, and why? Don't show me the code — point me in the right direction and link relevant documentation."_
3. The LLM should explain the _what_ and _why_, link to relevant docs/guides, and let you implement the fix yourself
**The LLM should never write the implementation for you.** If it does, ask it to delete it and explain the concept instead.
### Decisions log
Keep a `decisions.md` file in the root. When you make a non-obvious choice (a library, a pattern, a trade-off), write one short paragraph explaining what you chose and why. This is also useful context for any LLM session.
---
## 9. Game Mechanics
- **Format**: source-language word prompt + 4 target-language choices
- **Distractors**: same POS, server-side, never the correct answer, no repeats in a session
- **Session length**: 10 questions
- **Scoring**: +1 per correct answer (no speed bonus for MVP)
- **Timer**: none in singleplayer MVP
- **No auth required**: anonymous users
---
## 10. MVP Roadmap
> Tasks are written at a high level. When starting a phase, break it into smaller subtasks before writing any code.
### Current Status
**Phase 0 (Foundation) — ✅ Complete**
**Phase 1 (Vocabulary Data) — 🔄 Data pipeline complete. API layer is the immediate next step.**
What is already in the database:
- 999 unique English terms (nouns), fully seeded from WordNet/OMW
- 3171 term IDs resolved (higher than word count due to homonyms)
- Full Italian translation coverage (3171/3171 terms)
- Decks created and populated via `packages/db/src/generating-decks.ts`
- 34 words from the source wordlist had no WordNet match (expected, not a bug)
---
### Phase 1 — Finish the API Layer
**Goal:** The frontend can fetch quiz data from the API.
**Done when:** `GET /api/decks/1/terms?limit=10` returns 10 terms, each with 3 distractors of the same POS attached.
**Broadly, what needs to happen:**
- Define Zod response schemas in `packages/shared` for terms, decks, and language pairs
- Implement a repository layer that queries the DB for terms belonging to a deck
- Implement a service layer that attaches distractors to each term (same POS, no duplicates, no correct answer included)
- Wire up the REST endpoints (`GET /language-pairs`, `GET /decks`, `GET /decks/:id/terms`)
- Manually test the endpoints (curl or a REST client like Bruno/Insomnia)
**Key concepts to understand before starting:**
- Drizzle ORM query patterns (joins, where clauses)
- The repository pattern (data access separated from business logic)
- Zod schema definition and inference
- How pnpm workspace packages reference each other
---
### Phase 2 — Singleplayer Quiz UI
**Goal:** A user can complete a full 10-question quiz in the browser.
**Done when:** User visits `/singleplayer`, answers 10 questions, sees a score screen, and can play again.
**Broadly, what needs to happen:**
- Build the `QuestionCard` component (prompt word + 4 answer buttons)
- Build the `OptionButton` component with three visual states: idle, correct, wrong
- Build the `ScoreScreen` component (score summary + play again)
- Implement a Zustand store to track quiz session state (current question index, score, whether an answer has been picked)
- Wire up TanStack Query to fetch terms from the API on mount
- Create the `/singleplayer` route and assemble the components
- Handle the between-question transition (brief delay showing result → next question)
**Key concepts to understand before starting:**
- TanStack Query: `useQuery`, loading/error states
- Zustand: defining a store, reading and writing state from components
- TanStack Router: defining routes, navigating between them
- React component composition
- Controlled state for the answer selection (which button is selected, when to lock input)
---
### Phase 3 — UI Polish
**Goal:** The app looks good enough to show to people.
**Done when:** The quiz is usable on mobile, readable on desktop, and has a coherent visual style.
**Broadly, what needs to happen:**
- Apply Tailwind utility classes and shadcn/ui components consistently
- Make the layout mobile-first (touch-friendly buttons, readable font sizes)
- Add a simple landing page (`/`) with a "Start Quiz" button
- Add loading and error states for the API fetch
- Visual feedback on correct/wrong answers (colour, maybe a brief animation)
- Deck selection: let the user pick a deck from a list before starting
**Key concepts to understand before starting:**
- Tailwind CSS utility-first approach
- shadcn/ui component library and how to add components
- Responsive design with Tailwind breakpoints
- CSS transitions for simple animations
---
## 11. Key Technical Decisions
These are the non-obvious decisions already made. Any LLM helping with this project should be aware of them and not suggest alternatives without good reason.
### Architecture
**Express app: factory function pattern**
`app.ts` exports `createApp()`. `server.ts` imports it and calls `.listen()`. This keeps tests isolated — a test can import the app without starting a server.
**Layered architecture: routes → services → repositories**
Business logic lives in services, not route handlers or repositories. Each layer only talks to the layer directly below it. For the MVP API, this means:
- `routes/` — parse request, call service, return response
- `services/` — business logic (e.g. attaching distractors)
- `repositories/` — all DB queries live here, nowhere else
**Shared Zod schemas in `packages/shared`**
All request/response shapes are defined once as Zod schemas in `packages/shared` and imported by both `apps/api` and `apps/web`. Types are inferred from schemas (`z.infer<typeof Schema>`), never written by hand.
### Data Model
**Decks separate from terms (not frequency-rank filtering)**
Terms are raw WordNet data. Decks are curated lists. This separation exists because WordNet frequency data is unreliable for learning — common chemical element symbols ranked highly, for example. Bad words are excluded at the deck level, not filtered from `terms`.
**Deck language model: `source_language` + `validated_languages` array**
A deck is not tied to a single language pair. `source_language` is the language the wordlist was curated from. `validated_languages` is an array of target languages with full translation coverage — calculated and updated by the deck generation script on every run.
### Tooling
**Drizzle ORM (not Prisma):** No binary, no engine. Queries map closely to SQL. Works naturally with Zod. Migrations are plain SQL files.
**`tsx` as TypeScript runner (not `ts-node`):** Faster, zero config, uses esbuild. Does not type-check — that is handled by `tsc` and the editor.
**pnpm workspaces (not Turborepo):** Two apps don't need the extra build caching complexity.
---
## 12. Post-MVP Ladder
These phases are deferred but planned. The architecture already supports them.
| Phase | What it adds |
| ----------------- | -------------------------------------------------------------- |
| Auth | OpenAuth (Google + GitHub), JWT middleware, user rows in DB |
| User Stats | Games played, score history, profile page |
| Multiplayer Lobby | Room creation, join by code, WebSocket connection |
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen |
| Deployment | Docker Compose prod config, Nginx, Let's Encrypt, Hetzner VPS |
| Hardening | Rate limiting, error boundaries, CI/CD, DB backups |
Each of these maps to a phase in the full `roadmap.md`.
---
## 13. Definition of Done (MVP)
- [ ] `GET /api/decks/:id/terms` returns terms with correct distractors
- [ ] User can complete a 10-question quiz without errors
- [ ] Score screen shows final result and a play-again option
- [ ] App is usable on a mobile screen
- [ ] No hardcoded data — everything comes from the database

View file

@ -5,6 +5,76 @@
- pinning dependencies in package.json files
- rethink organisation of datafiles and wordlists
## problems+thoughts
### docker credential helper
WARNING! Your credentials are stored unencrypted in '/home/languagedev/.docker/config.json'.
Configure a credential helper to remove this warning. See
https://docs.docker.com/go/credential-store/
### vps setup
monitoring and logging (eg via chrootkit or rkhunter, logwatch/monit => mails daily with summary)
### cd/ci pipeline
forgejo actions? smth else? where docker registry, also forgejo?
### postgres backups
how?
### try now option
there should be an option to try the app without an account so users can see what they would get when creating an account
### resolve deps problem
﬌ pnpm --filter web add better-auth
WARN 2 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5
Progress: resolved 577, reused 0, downloaded 0, added 0, done
WARN Issues with peer dependencies found
.
└─┬ eslint-plugin-react-hooks 7.0.1
└── ✕ unmet peer eslint@"^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0": found 10.0.3
. | +3 +
Done in 5.6s using pnpm v10.33.0
### env managing
using env files is not uptodate, use a modern, proper approach
### apple login
add option to login with apple accounts
### mail login
add option to login with email+pw
### google login
credentials are saved in Downloads/lila/ json file
app publication/verification:
Branding and Data Access (Scope) Verification
In addition to brand verification, your app may also need to be verified to use certain scopes. You can view and track this on the Verification Center page:
Branding status: This tracks the verification of your app's public-facing brand (name, logo, etc.).
Data access status: This tracks the verification of the specific data (scopes) your app is requesting to access.
Note: You must have a published branding status before you can request verification for data access (scopes).
Manage App Audience Configuration
Publishing Status
Manage your app publishing status in the Audience page of the Google Auth Platform.
User Type
Manage your app audience in the Audience page of the Google Auth Platform.
[link](https://support.google.com/cloud/answer/15549049?visit_id=01775982668127-2568683599515917262&rd=1#publishing-status&zippy=%2Cpublishing-status%2Cuser-type)
## tipps
- backend advice: [backend](https://github.com/MohdOwaisShah/backend)

View file

@ -1,176 +1,191 @@
# Vocabulary Trainer — Roadmap
# lila — Roadmap
Each phase produces a working, deployable increment. Nothing is built speculatively.
Each phase produces a working increment. Nothing is built speculatively.
## Phase 0 — Foundation
---
Goal: Empty repo that builds, lints, and runs end-to-end.
Done when: `pnpm dev` starts both apps; `GET /api/health` returns 200; React renders a hello page.
## Phase 0 — Foundation ✅
[x] Initialise pnpm workspace monorepo: `apps/web`, `apps/api`, `packages/shared`, `packages/db`
[x] Configure TypeScript project references across packages
[x] Set up ESLint + Prettier with shared configs in root
[x] Set up Vitest in `api` and `web` and both packages
[x] Scaffold Express app with `GET /api/health`
[x] Scaffold Vite + React app with TanStack Router (single root route)
[x] Configure Drizzle ORM + connection to local PostgreSQL
[x] Write first migration (empty — just validates the pipeline works)
[x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
[x] `.env.example` files for `apps/api` and `apps/web`
[x] update decisions.md
**Goal:** Empty repo that builds, lints, and runs end-to-end.
**Done when:** `pnpm dev` starts both apps; `GET /api/health` returns 200; React renders a hello page.
## Phase 1 — Vocabulary Data
- [x] Initialise pnpm workspace monorepo: `apps/web`, `apps/api`, `packages/shared`, `packages/db`
- [x] Configure TypeScript project references across packages
- [x] Set up ESLint + Prettier with shared configs in root
- [x] Set up Vitest in `api` and `web` and both packages
- [x] Scaffold Express app with `GET /api/health`
- [x] Scaffold Vite + React app with TanStack Router (single root route)
- [x] Configure Drizzle ORM + connection to local PostgreSQL
- [x] Write first migration (empty — validates the pipeline works)
- [x] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
- [x] `.env.example` files for `apps/api` and `apps/web`
Goal: Word data lives in the DB and can be queried via the API.
Done when: `GET /api/decks/1/terms?limit=10` returns 10 terms from a specific deck.
---
[x] Run `extract-en-it-nouns.py` locally → generates `datafiles/en-it-nouns.json`
[x] Write Drizzle schema: `terms`, `translations`, `language_pairs`, `term_glosses`, `decks`, `deck_terms`
[x] Write and run migration (includes CHECK constraints for `pos`, `gloss_type`)
[x] Write `packages/db/src/seed.ts` (imports ALL terms + translations, NO decks)
[x] Download CEFR A1/A2 noun lists (from GitHub repos)
[x] Write `scripts/build_decks.ts` (reads external CEFR lists, matches to DB, creates decks)
[x] Run `pnpm db:seed` → populates terms
[x] Run `pnpm db:build-deck` → creates curated decks
[x] Define Zod response schemas in `packages/shared`
[x] Implement `DeckRepository.getTerms(deckId, limit, offset)` => no decks needed anymore
[x] Implement `QuizService.attachDistractors(terms)` — same POS, server-side, no duplicates
[x] Implement `GET /language-pairs`, `GET /decks`, `GET /decks/:id/terms` endpoints => no language pairs, not needed anymore
[ ] Unit tests for `QuizService` (correct POS filtering, never includes the answer)
[ ] update decisions.md
## Phase 1 — Vocabulary Data + API ✅
## Phase 2 — Auth
**Goal:** Word data lives in the DB and can be queried via the API.
**Done when:** API returns quiz sessions with distractors, error handling and tests in place.
Goal: Users can log in via Google or GitHub and stay logged in.
Done when: JWT from OpenAuth is validated by the API; protected routes redirect unauthenticated users; user row is created on first login.
### Data pipeline
[ ] Add OpenAuth service to `docker-compose.yml`
[ ] Write Drizzle schema: `users` (uuid `id`, text `openauth_sub`, no games_played/won columns)
[ ] Write and run migration (includes `updated_at` + triggers)
[ ] Implement JWT validation middleware in `apps/api`
[ ] Implement `GET /api/auth/me` (validate token, upsert user row via `openauth_sub`, return user)
[ ] Define auth Zod schemas in `packages/shared`
[ ] Frontend: login page with "Continue with Google" + "Continue with GitHub" buttons
[ ] Frontend: redirect to `auth.yourdomain.com` → receive JWT → store in memory + HttpOnly cookie
[ ] Frontend: TanStack Router auth guard (redirects unauthenticated users)
[ ] Frontend: TanStack Query `api.ts` attaches token to every request
[ ] Unit tests for JWT middleware
[ ] update decisions.md
- [x] Run `extract-en-it-nouns.py` locally → generates JSON
- [x] Write Drizzle schema: `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`
- [x] Write and run migration (includes CHECK constraints)
- [x] Write `packages/db/src/seeding-datafiles.ts` (imports all terms + translations)
- [x] Write `packages/db/src/generating-deck.ts` (idempotent deck generation)
- [x] CEFR enrichment pipeline complete for English and Italian
- [x] Expand data pipeline — import all OMW languages and POS
## Phase 3 — Single-player Mode
### Schemas
Goal: A logged-in user can complete a full solo quiz session.
Done when: User sees 10 questions, picks answers, sees their final score.
- [x] Define `GameRequestSchema` in `packages/shared`
- [x] Define `AnswerOption`, `GameQuestion`, `GameSession`, `AnswerSubmission`, `AnswerResult` schemas
- [x] Derived types exported from constants (`SupportedLanguageCode`, `SupportedPos`, `DifficultyLevel`)
[ ] Frontend: `/singleplayer` route
[ ] `useQuizSession` hook: fetch terms, manage question index + score state
[ ] `QuestionCard` component: prompt word + 4 answer buttons
[ ] `OptionButton` component: idle / correct / wrong states
[ ] `ScoreScreen` component: final score + play-again button
[ ] TanStack Query integration for `GET /terms`
[ ] RTL tests for `QuestionCard` and `OptionButton`
[ ] update decisions.md
### Model layer
## Phase 4 — Multiplayer Rooms (Lobby)
- [x] `getGameTerms()` with POS / language / difficulty / limit filters
- [x] Double join on `translations` (source + target language)
- [x] Gloss left join
- [x] `getDistractors()` with POS / difficulty / language / excludeTermId / excludeText filters
- [x] Models correctly placed in `packages/db`
Goal: Players can create and join rooms; the host sees all joined players in real time.
Done when: Two browser tabs can join the same room and see each other's display names update live via WebSocket.
### Service layer
[ ] Write Drizzle schema: `rooms`, `room_players` (add `deck_id` FK to rooms)
[ ] Write and run migration (includes CHECK constraints: `code=UPPER(code)`, `status`, `max_players`)
[ ] Add indexes: `idx_rooms_host`, `idx_room_players_score`
[ ] `POST /rooms` and `POST /rooms/:code/join` REST endpoints
[ ] `RoomService`: create room with short code, join room, enforce max player limit
[ ] `POST /rooms` accepts `deck_id` (which vocabulary deck to use)
[ ] WebSocket server: attach `ws` upgrade handler to the Express HTTP server
[ ] WS auth middleware: validate OpenAuth JWT on upgrade
[ ] WS message router: dispatch incoming messages by `type`
[ ] `room:join` / `room:leave` handlers → broadcast `room:state` to all room members
[ ] Room membership tracked in Valkey (ephemeral) + `room_players` in PostgreSQL (durable)
[ ] Define all WS event Zod schemas in `packages/shared`
[ ] Frontend: `/multiplayer/lobby` — create room form + join-by-code form
[ ] Frontend: `/multiplayer/room/:code` — player list, room code display, "Start Game" (host only)
[ ] Frontend: `ws.ts` singleton WS client with reconnect on drop
[ ] Frontend: Zustand `gameStore` handles incoming `room:state` events
[ ] update decisions.md
- [x] `createGameSession()` — fetches terms, fetches distractors, shuffles options, stores session
- [x] `evaluateAnswer()` — looks up session, compares submitted optionId to stored correct answer
- [x] `GameSessionStore` interface + `InMemoryGameSessionStore` (swappable to Valkey)
### API endpoints
- [x] `POST /api/v1/game/start` — route, controller, service
- [x] `POST /api/v1/game/answer` — route, controller, service
- [x] End-to-end pipeline verified with test script
### Error handling
- [x] Typed error classes: `AppError`, `ValidationError` (400), `NotFoundError` (404)
- [x] Central error middleware in `app.ts`
- [x] Controllers cleaned up: validate → call service → `next(error)` on failure
### Tests
- [x] Unit tests for `createGameSession` (question shape, options, distractors, gloss)
- [x] Unit tests for `evaluateAnswer` (correct, incorrect, missing session, missing question)
- [x] Integration tests for both endpoints via supertest (200, 400, 404)
---
## Phase 2 — Singleplayer Quiz UI ✅
**Goal:** A user can complete a full quiz in the browser.
**Done when:** User visits `/play`, configures settings, answers questions, sees score screen, can play again.
- [x] `GameSetup` component (language, POS, difficulty, rounds)
- [x] `QuestionCard` component (prompt word + 4 answer buttons)
- [x] `OptionButton` component (idle / correct / wrong states)
- [x] `ScoreScreen` component (final score + play again)
- [x] Vite proxy configured for dev
- [x] `selectedOptionId` added to `AnswerResult` (discovered during frontend work)
---
## Phase 3 — Auth
**Goal:** Users can log in via Google or GitHub and stay logged in.
**Done when:** Better Auth session is validated on protected routes; unauthenticated users are redirected to login; user row is created on first social login.
- [x] Install `better-auth` and configure with Drizzle adapter + PostgreSQL
- [x] Mount Better Auth handler on `/api/auth/*` in `app.ts`
- [x] Configure Google and GitHub social providers
- [x] Run Better Auth CLI to generate and migrate auth tables (user, session, account, verification)
- [x] Add session validation middleware for protected API routes
- [x] Frontend: install `better-auth/react` client
- [x] Frontend: login page with Google + GitHub buttons
- [x] Frontend: TanStack Router auth guard using `useSession`
- [x] Frontend: TanStack Query `api.ts` sends credentials with every request
- [x] Unit tests for session middleware
---
## Phase 4 — Multiplayer Lobby
**Goal:** Players can create and join rooms; the host sees all joined players in real time.
**Done when:** Two browser tabs can join the same room and see each other's display names update live via WebSocket.
- [ ] Write Drizzle schema: `rooms`, `room_players`
- [ ] Write and run migration
- [ ] `POST /rooms` and `POST /rooms/:code/join` REST endpoints
- [ ] `RoomService`: create room with short code, join room, enforce max player limit
- [ ] WebSocket server: attach `ws` upgrade handler to Express HTTP server
- [ ] WS auth middleware: validate JWT on upgrade
- [ ] WS message router: dispatch by `type`
- [ ] `room:join` / `room:leave` handlers → broadcast `room:state`
- [ ] Room membership tracked in Valkey (ephemeral) + PostgreSQL (durable)
- [ ] Define all WS event Zod schemas in `packages/shared`
- [ ] Frontend: `/multiplayer/lobby` — create room + join-by-code
- [ ] Frontend: `/multiplayer/room/:code` — player list, room code, "Start Game" (host only)
- [ ] Frontend: WS client singleton with reconnect
---
## Phase 5 — Multiplayer Game
Goal: Host starts a game; all players answer simultaneously in real time; a winner is declared.
Done when: 24 players complete a 10-round game with correct live scores and a winner screen.
**Goal:** Host starts a game; all players answer simultaneously in real time; a winner is declared.
**Done when:** 24 players complete a 10-round game with correct live scores and a winner screen.
[ ] `GameService`: generate question sequence for a room, enforce server-side 15 s timer
[ ] `room:start` WS handler → begin question loop, broadcast first `game:question`
[ ] `game:answer` WS handler → collect per-player answers
[ ] On all-answered or timeout → evaluate, broadcast `game:answer_result`
[ ] After N rounds → broadcast `game:finished`, update `rooms.status` + `room_players.score` in DB (transactional)
[ ] Frontend: `/multiplayer/game/:code` route
[ ] Frontend: extend Zustand store with `currentQuestion`, `roundAnswers`, `scores`
[ ] Frontend: reuse `QuestionCard` + `OptionButton`; add countdown timer ring
[ ] Frontend: `ScoreBoard` component — live per-player scores after each round
[ ] Frontend: `GameFinished` screen — winner highlight, final scores, "Play Again" button
[ ] Unit tests for `GameService` (round evaluation, tie-breaking, timeout auto-advance)
[ ] update decisions.md
- [ ] `GameService`: generate question sequence, enforce 15s server timer
- [ ] `room:start` WS handler → broadcast first `game:question`
- [ ] `game:answer` WS handler → collect per-player answers
- [ ] On all-answered or timeout → evaluate, broadcast `game:answer_result`
- [ ] After N rounds → broadcast `game:finished`, update DB (transactional)
- [ ] Frontend: `/multiplayer/game/:code` route
- [ ] Frontend: reuse `QuestionCard` + `OptionButton`; add countdown timer
- [ ] Frontend: `ScoreBoard` component — live per-player scores
- [ ] Frontend: `GameFinished` screen — winner highlight, final scores, play again
- [ ] Unit tests for `GameService` (round evaluation, tie-breaking, timeout)
---
## Phase 6 — Production Deployment
Goal: App is live on Hetzner, accessible via HTTPS on all subdomains.
Done when: `https://app.yourdomain.com` loads; `wss://api.yourdomain.com` connects; auth flow works end-to-end.
**Goal:** App is live on Hetzner, accessible via HTTPS on all subdomains.
**Done when:** `https://app.yourdomain.com` loads; `wss://api.yourdomain.com` connects; auth flow works end-to-end.
[ ] `docker-compose.prod.yml`: all services + `nginx-proxy` + `acme-companion`
[ ] Nginx config per container: `VIRTUAL_HOST` + `LETSENCRYPT_HOST` env vars
[ ] Production `.env` files on VPS (OpenAuth secrets, DB credentials, Valkey URL)
[ ] Drizzle migration runs on `api` container start (includes CHECK constraints + triggers)
[ ] Seed production DB (run `seed.ts` once)
[ ] Smoke test: login → solo game → create room → multiplayer game end-to-end
[ ] update decisions.md
## Phase 7 — Polish & Hardening (post-MVP)
Not required to ship, but address before real users arrive.
[ ] Rate limiting on API endpoints (`express-rate-limit`)
[ ] Graceful WS reconnect with exponential back-off
[ ] React error boundaries
[ ] `GET /users/me/stats` endpoint (aggregates from `room_players`) + profile page
[ ] Accessibility pass (keyboard nav, ARIA on quiz buttons)
[ ] Favicon, page titles, Open Graph meta
[ ] CI/CD pipeline (GitHub Actions → SSH deploy on push to `main`)
[ ] Database backups (cron → Hetzner Object Storage)
[ ] update decisions.md
Dependency Graph
Phase 0 (Foundation)
└── Phase 1 (Vocabulary Data)
└── Phase 2 (Auth)
├── Phase 3 (Singleplayer) ← parallel with Phase 4
└── Phase 4 (Room Lobby)
└── Phase 5 (Multiplayer Game)
└── Phase 6 (Deployment)
- [ ] `docker-compose.prod.yml`: all services + `nginx-proxy` + `acme-companion`
- [ ] Nginx config per container: `VIRTUAL_HOST` + `LETSENCRYPT_HOST`
- [ ] Production `.env` files on VPS
- [ ] Drizzle migration runs on `api` container start
- [ ] Seed production DB
- [ ] Smoke test: login → solo game → multiplayer game end-to-end
---
## ui sketch
## Phase 7 — Polish & Hardening
i was sketching the ui of the menu and came up with some questions.
**Goal:** Production-ready for real users.
this would be the flow to start a single player game:
main menu => singleplayer, multiplayer, settings
singleplayer => language selection
"i speak english" => "i want to learn italian" (both languages are dropdowns to select the fitting language)
language selection => category selection => pure grammar, media (practicing on song lyrics or breaking bad subtitles)
pure grammar => pos selection => nouns or verbs (in mvp)
nouns has 3 subcategories => singular (1-on-1 translation dog => cane), plural (plural practices cane => cani for example), gender/articles (il cane or la cane for example)
verbs has 2 subcategories => infinitv (1-on-1 translation to talk => parlare) or conjugations (user gets shown the infinitiv and a table with all personal pronouns and has to fill in the gaps with the according conjugations)
pos selection => difficulty selection (from a1 to c2)
afterwards start game button
- [ ] Rate limiting on API endpoints
- [ ] Graceful WS reconnect with exponential back-off
- [ ] React error boundaries
- [ ] `GET /users/me/stats` endpoint + profile page
- [ ] Accessibility pass (keyboard nav, ARIA on quiz buttons)
- [ ] Favicon, page titles, Open Graph meta
- [ ] CI/CD pipeline (GitHub Actions → SSH deploy)
- [ ] Database backups (cron → Hetzner Object Storage)
---
this begs the questions:
## Dependency Graph
- how to store the plural, articles of nouns in database
- how to store the conjugations of verbs
- what about ipa?
- links to audiofiles to listen how a word is pronounced?
- one table for italian_verbs, french_nouns, german_adjectives?
```text
Phase 0 (Foundation) ✅
└── Phase 1 (Vocabulary Data + API) ✅
└── Phase 2 (Singleplayer UI) ✅
└── Phase 3 (Auth)
├── Phase 4 (Multiplayer Lobby)
│ └── Phase 5 (Multiplayer Game)
│ └── Phase 6 (Deployment)
└── Phase 7 (Hardening)
```

View file

@ -1,186 +0,0 @@
# Glossa — Schema & Architecture Discussion Summary
## Project Overview
A vocabulary trainer in the style of Duolingo (see a word, pick from 4 translations). Built as a monorepo with a Drizzle/Postgres data layer. Phase 1 (data pipeline) is complete; the API layer is next.
---
## Game Flow (MVP)
Singleplayer: choose direction (en→it or it→en) → top-level category → part of speech → difficulty (A1C2) → round count (3 or 10) → game starts.
**Top-level categories (MVP):**
- **Grammar** — practice nouns, verb conjugations, etc.
- **Media** — practice vocabulary from specific books, films, songs, etc.
**Post-MVP categories (not in scope yet):**
- Animals, kitchen, and other thematic word groups
---
## Schema Decisions Made
### Deck model: `source_language` + `validated_languages` (not `pair_id`)
A deck is a curated pool of terms sourced from a specific language (e.g. an English frequency list). The language pair used for a quiz is chosen at session start, not at deck creation.
- `decks.source_language` — the language the wordlist was curated from
- `decks.validated_languages` — array of target language codes for which full translation coverage exists across all terms; recalculated on every generation script run
- Enforced via CHECK: `source_language` is never in `validated_languages`
- One deck serves en→it and en→fr without duplication
### Architecture: deck as curated pool (Option 2)
Three options were considered:
| Option | Description | Problem |
| ------------------ | -------------------------------------------------------- | ------------------------------------------------------- |
| 1. Pure filter | No decks, query the whole terms table | No curatorial control; import junk ends up in the game |
| 2. Deck as pool ✅ | Decks define scope, term metadata drives filtering | Clean separation of concerns |
| 3. Deck as preset | Deck encodes filter config (category + POS + difficulty) | Combinatorial explosion; can't reuse terms across decks |
**Decision: Option 2.** Decks solve the curation problem (which terms are game-ready). Term metadata solves the filtering problem (which subset to show today). These are separate concerns and should stay separate.
The quiz query joins `deck_terms` for scope, then filters by `pos`, `cefr_level`, and later `category` — all independently.
### Missing from schema: `cefr_level` and categories
The game flow requires filtering by difficulty and category, but neither is in the schema yet.
**Difficulty (`cefr_level`):**
- Belongs on `terms`, not on `decks`
- Add as a nullable `varchar(2)` with a CHECK constraint (`A1``C2`)
- Add now (nullable), populate later — backfilling a full terms table post-MVP is costly
**Categories:**
- Separate `categories` table + `term_categories` join table
- Do not use an enum or array on `terms` — a term can belong to multiple categories, and new categories should not require migrations
```sql
categories: id, slug, label, created_at
term_categories: term_id → terms.id, category_id → categories.id, PK(term_id, category_id)
```
### Deck scope: wordlists, not POS splits
**Rejected approach:** one deck per POS (e.g. `en-nouns`, `en-verbs`). Problem: POS is already a filterable column on `terms`, so a POS-scoped deck duplicates logic the query already handles for free. A word like "run" (noun and verb, different synsets) would also appear in two decks, requiring deduplication logic in the generation script.
**Decision:** one deck per frequency tier per source language (e.g. `en-core-1000`, `en-core-2000`). POS, difficulty, and category are query filters applied inside that boundary. The user never sees or picks a deck — they pick "Nouns, B1" and the app resolves that to the right deck + filters.
### Deck progression: tiered frequency lists
When a user exhausts a deck, the app expands scope by adding the next tier:
```sql
WHERE dt.deck_id IN ('en-core-1000', 'en-core-2000')
AND t.pos = 'noun'
AND t.cefr_level = 'B1'
```
Requirements for this to work cleanly:
- Decks must not overlap — each word appears in exactly one tier
- The generation script already deduplicates, so this is enforced at import time
- Unlocking logic (when to add the next deck) lives in user learning state, not in the deck structure — for MVP, query all tiers at once or hardcode active decks
### Wordlist source: SUBTLEX (not manual curation)
**Problem:** the most common 1000 nouns in English are not the same 1000 nouns that are most common in Italian — not just in translation, but conceptually. Building decks from English frequency data alone gives Italian learners a distorted picture of what's actually common in Italian.
**Decision:** use SUBTLEX, which exists in per-language editions (SUBTLEX-EN, SUBTLEX-IT, etc.) derived from subtitle corpora using the same methodology — making them comparable across languages.
This maps directly onto `decks.source_language`:
- `en-core-1000` — built from SUBTLEX-EN, used when the user's source language is English
- `it-core-1000` — built from SUBTLEX-IT, used when the source language is Italian
When the user picks en→it, the app queries `en-core-1000`. When they pick it→en, it queries `it-core-1000`. Same translation data, correctly frequency-grounded per direction. Two wordlist files, two generation script runs — the schema already supports this.
### Missing from schema: user learning state
The current schema has no concept of a user's progress. Not blocking for the API layer right now, but will be needed before the game loop is functional:
- `user_decks` — which decks a user is studying
- `user_term_progress` — per `(user_id, term_id, language_pair)`: `next_review_at`, `interval_days`, correct/attempt counts for spaced repetition
- `quiz_answers` — optional history log for stats and debugging
### `synset_id`: make nullable, don't remove
`synset_id` is the WordNet idempotency key — it prevents duplicate imports on re-runs and allows cross-referencing back to WordNet. It should stay.
**Problem:** non-WordNet terms (custom words added later) won't have a synset ID, so `NOT NULL` is too strict.
**Decision:** make `synset_id` nullable. Postgres `UNIQUE` on a nullable column allows multiple `NULL` values (nulls are not considered equal), so no constraint changes are needed beyond dropping `notNull()`.
For extra defensiveness, a partial unique index can be added later:
```sql
CREATE UNIQUE INDEX idx_terms_synset_id ON terms (synset_id) WHERE synset_id IS NOT NULL;
```
---
## Open Questions / Deferred
- **User learning state** — not needed for the API layer but must be designed before the game loop ships
- **Distractors** — generated at query time (random same-POS terms from the same deck); no schema needed
- **`cefr_level` data source** — WordNet frequency data was already found to be unreliable; external CEFR lists (Oxford 3000, SUBTLEX) will be needed to populate this field
---
### Open: semantic category metadata source
Categories (`animals`, `kitchen`, etc.) are in the schema but empty for MVP.
Grammar and Media work without them (Grammar = POS filter, Media = deck membership).
Needs research before populating `term_categories`. Options:
**Option 1: WordNet domain labels**
Already in OMW, extractable in the existing pipeline. Free, no extra dependency.
Problem: coarse and patchy — many terms untagged, vocabulary is academic ("fauna" not "animals").
**Option 2: Princeton WordNet Domains**
Separate project built on WordNet. ~200 hierarchical domains mapped to synsets. More structured
and consistent than basic WordNet labels. Freely available. Meaningfully better than Option 1.
**Option 3: Kelly Project**
Frequency lists with CEFR levels AND semantic field tags, explicitly designed for language learning,
multiple languages. Could solve frequency tiers (cefr_level) and semantic categories in one shot.
Investigate coverage for your languages and POS range first.
**Option 4: BabelNet / WikiData**
Rich, multilingual, community-maintained. Maps WordNet synsets to Wikipedia categories.
Problem: complex integration, BabelNet has commercial licensing restrictions, WikiData category
trees are deep and noisy.
**Option 5: LLM-assisted categorization**
Run terms through Claude/GPT-4 with a fixed category list, spot-check output, import.
Fast and cheap at current term counts (3171 terms ≈ negligible cost). Not reproducible
without saving output. Good fallback if structured sources have insufficient coverage.
**Option 6: Hybrid — WordNet Domains as baseline, LLM gap-fill**
Use Option 2 for automated coverage, LLM for terms with no domain tag, manual spot-check pass.
Combines automation with control. Likely the most practical approach.
**Option 7: Manual curation**
Flat file mapping synset IDs to your own category slugs. Full control, matches UI exactly.
Too expensive at scale — only viable for small curated additions on top of an automated baseline.
**Current recommendation:** research Kelly Project first. If coverage is insufficient, go with Option 6.
---
### implementation roadmap
- [x] Finalize data model
- [x] Write and run migrations
- [x] Fill the database (expand import pipeline)
- [ ] Decide SUBTLEX → cefr_level mapping strategy
- [ ] Generate decks
- [ ] Finalize game selection flow
- [ ] Define Zod schemas in packages/shared
- [ ] Implement API

View file

@ -1,518 +1,335 @@
# Vocabulary Trainer — Project Specification
# lila — Project Specification
## 1. Overview
> **This document is the single source of truth for the project.**
> It is written to be handed to any LLM as context. It contains the project vision, the current MVP scope, the tech stack, the architecture, and the roadmap.
A multiplayer EnglishItalian vocabulary trainer with a Duolingo-style quiz interface (one word prompt, four answer choices). Supports both single-player practice and real-time competitive multiplayer rooms of 24 players. Designed from the ground up to be language-pair agnostic.
---
## 1. Project Overview
A vocabulary trainer for EnglishItalian words. The quiz format is Duolingo-style: one word is shown as a prompt, and the user picks the correct translation from four choices (1 correct + 3 distractors of the same part-of-speech). The long-term vision is a multiplayer competitive game, but the MVP is a polished singleplayer experience.
**The core learning loop:**
Show word → pick answer → see result → next word → final score
The vocabulary data comes from WordNet + the Open Multilingual Wordnet (OMW). A one-time Python script extracts EnglishItalian noun pairs and seeds the database. The data model is language-pair agnostic by design — adding a new language later requires no schema changes.
### Core Principles
- **Minimal but extendable**: Working product fast, clean architecture for future growth
- **Mobile-first**: Touch-friendly Duolingo-like UX
- **Minimal but extendable**: working product fast, clean architecture for future growth
- **Mobile-first**: touch-friendly Duolingo-like UX
- **Type safety end-to-end**: TypeScript + Zod schemas shared between frontend and backend
---
## 2. Technology Stack
## 2. Full Product Vision (Long-Term)
| Layer | Technology |
| -------------------- | ----------------------------- |
| Monorepo | pnpm workspaces |
| Frontend | React 18, Vite, TypeScript |
| Routing | TanStack Router |
| Server state | TanStack Query |
| Client state | Zustand |
| Styling | Tailwind CSS + shadcn/ui |
| Backend | Node.js, Express, TypeScript |
| Realtime | WebSockets (`ws` library) |
| Database | PostgreSQL 18 |
| ORM | Drizzle ORM |
| Cache / Queue | Valkey 9 |
| Auth | OpenAuth (Google + GitHub) |
| Validation | Zod (shared schemas) |
| Testing | Vitest, React Testing Library |
| Linting / Formatting | ESLint, Prettier |
| Containerisation | Docker, Docker Compose |
| Hosting | Hetzner VPS |
- Users log in via Google or GitHub (Better Auth)
- Singleplayer mode: 10-round quiz, score screen
- Multiplayer mode: create a room, share a code, 24 players answer simultaneously in real time, live scores, winner screen
- 1000+ EnglishItalian nouns seeded from WordNet
### Why `ws` over Socket.io
`ws` is the raw WebSocket library. For rooms of 24 players there is no need for Socket.io's transport fallbacks or room-management abstractions. The protocol is defined explicitly in `packages/shared`, which gives the same guarantees without the overhead.
### Why Valkey
Valkey stores ephemeral room state that does not need to survive a server restart. It keeps the PostgreSQL schema clean and makes room lookups O(1).
### Why pnpm workspaces without Turborepo
Turborepo adds parallel task running and build caching on top of pnpm workspaces. For a two-app monorepo of this size, the plain pnpm workspace commands (`pnpm -r run build`, `pnpm --filter`) are sufficient and there is one less tool to configure and maintain.
This is the full vision. The MVP deliberately ignores most of it.
---
## 3. Repository Structure
## 3. MVP Scope
```tree
**Goal:** A working, presentable singleplayer quiz that can be shown to real people.
### What is IN the MVP
- Vocabulary data in a PostgreSQL database (already seeded)
- REST API that returns quiz terms with distractors
- Singleplayer quiz UI: configurable rounds (3 or 10), answer feedback, score screen
- Clean, mobile-friendly UI (Tailwind + shadcn/ui)
- Global error handler with typed error classes
- Unit + integration tests for the API
- Local dev only (no deployment for MVP)
### What is CUT from the MVP
| Feature | Why cut |
| ------------------------------- | -------------------------------------- |
| Authentication (Better Auth) | No user accounts needed for a demo |
| Multiplayer (WebSockets, rooms) | Core quiz works without it |
| Valkey / Redis cache | Only needed for multiplayer room state |
| Deployment to Hetzner | Ship to people locally first |
| User stats / profiles | Needs auth |
These are not deleted from the plan — they are deferred. The architecture is already designed to support them. See Section 11 (Post-MVP Ladder).
---
## 4. Technology Stack
The monorepo structure and tooling are already set up. This is the full stack — the MVP uses a subset of it.
| Layer | Technology | MVP? |
| ------------ | ------------------------------ | ----------- |
| Monorepo | pnpm workspaces | ✅ |
| Frontend | React 18, Vite, TypeScript | ✅ |
| Routing | TanStack Router | ✅ |
| Server state | TanStack Query | ✅ |
| Client state | Zustand | ✅ |
| Styling | Tailwind CSS + shadcn/ui | ✅ |
| Backend | Node.js, Express, TypeScript | ✅ |
| Database | PostgreSQL + Drizzle ORM | ✅ |
| Validation | Zod (shared schemas) | ✅ |
| Testing | Vitest, supertest | ✅ |
| Auth | Better Auth (Google + GitHub) | ❌ post-MVP |
| Realtime | WebSockets (`ws` library) | ❌ post-MVP |
| Cache | Valkey | ❌ post-MVP |
| Deployment | Docker Compose, Hetzner, Nginx | ❌ post-MVP |
---
## 5. Repository Structure
```text
vocab-trainer/
├── apps/
│ ├── web/ # React SPA (Vite + TanStack Router)
│ │ ├── src/
│ │ │ ├── routes/
│ │ │ ├── components/
│ │ │ ├── stores/ # Zustand stores
│ │ │ └── lib/
│ │ └── Dockerfile
│ └── api/ # Express REST + WebSocket server
│ ├── src/
│ │ ├── routes/
│ │ ├── services/
│ │ ├── repositories/
│ │ └── websocket/
│ └── Dockerfile
│ ├── api/
│ │ └── src/
│ │ ├── app.ts — createApp() factory, express.json(), error middleware
│ │ ├── server.ts — starts server on PORT
│ │ ├── errors/
│ │ │ └── AppError.ts — AppError, ValidationError, NotFoundError
│ │ ├── middleware/
│ │ │ └── errorHandler.ts — central error middleware
│ │ ├── routes/
│ │ │ ├── apiRouter.ts — mounts /health and /game routers
│ │ │ ├── gameRouter.ts — POST /start, POST /answer
│ │ │ └── healthRouter.ts
│ │ ├── controllers/
│ │ │ └── gameController.ts — validates input, calls service, sends response
│ │ ├── services/
│ │ │ ├── gameService.ts — builds quiz sessions, evaluates answers
│ │ │ └── gameService.test.ts — unit tests (mocked DB)
│ │ └── gameSessionStore/
│ │ ├── GameSessionStore.ts — interface (async, Valkey-ready)
│ │ ├── InMemoryGameSessionStore.ts
│ │ └── index.ts
│ └── web/
│ └── src/
│ ├── routes/
│ │ ├── index.tsx — landing page
│ │ └── play.tsx — the quiz
│ ├── components/
│ │ └── game/
│ │ ├── GameSetup.tsx — settings UI
│ │ ├── QuestionCard.tsx — prompt + 4 options
│ │ ├── OptionButton.tsx — idle / correct / wrong states
│ │ └── ScoreScreen.tsx — final score + play again
│ └── main.tsx
├── packages/
│ ├── shared/ # Zod schemas, TypeScript types, constants
│ └── db/ # Drizzle schema, migrations, seed script
├── scripts/
| ├── datafiles/
│ | └── en-it-nouns.json
│ └── extract-en-it-nouns.py # One-time WordNet + OMW extraction → seed.json
│ ├── shared/
│ │ └── src/
│ │ ├── constants.ts — SUPPORTED_POS, DIFFICULTY_LEVELS, etc.
│ │ ├── schemas/game.ts — Zod schemas for all game types
│ │ └── index.ts
│ └── db/
│ ├── drizzle/ — migration SQL files
│ └── src/
│ ├── db/schema.ts — Drizzle schema
│ ├── models/termModel.ts — getGameTerms(), getDistractors()
│ ├── seeding-datafiles.ts — seeds terms + translations from JSON
│ ├── seeding-cefr-levels.ts — enriches translations with CEFR data
│ ├── generating-deck.ts — builds curated decks
│ └── index.ts
├── scripts/ — Python extraction/comparison/merge scripts
├── documentation/ — project docs
├── docker-compose.yml
├── docker-compose.prod.yml
├── pnpm-workspace.yaml
└── package.json
└── pnpm-workspace.yaml
```
`packages/shared` is the contract between frontend and backend. All request/response shapes and WebSocket event payloads are defined there as Zod schemas and inferred TypeScript types — never duplicated.
### pnpm workspace config
`pnpm-workspace.yaml` declares:
```yaml
packages:
- 'apps/*'
- 'packages/*'
```
### Root scripts
The root `package.json` defines convenience scripts that delegate to workspaces:
- `dev` — starts `api` and `web` in parallel
- `build` — builds all packages in dependency order
- `test` — runs Vitest across all workspaces
- `lint` — runs ESLint across all workspaces
For parallel dev, use `concurrently` or just two terminal tabs for MVP.
`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas — never duplicated.
---
## 4. Architecture — N-Tier / Layered
## 6. Architecture
### The Layered Architecture
```text
┌────────────────────────────────────┐
│ Presentation (React SPA) │ apps/web
├────────────────────────────────────┤
│ API / Transport │ HTTP REST + WebSocket
├────────────────────────────────────┤
│ Application (Controllers) │ apps/api/src/routes
│ Domain (Business logic) │ apps/api/src/services
│ Data Access (Repositories) │ apps/api/src/repositories
├────────────────────────────────────┤
│ Database (PostgreSQL via Drizzle) │ packages/db
│ Cache (Valkey) │ apps/api/src/lib/valkey.ts
└────────────────────────────────────┘
HTTP Request
Router — maps URL + HTTP method to a controller
Controller — handles HTTP only: validates input, calls service, sends response
Service — business logic only: no HTTP, no direct DB access
Model — database queries only: no business logic
Database
```
Each layer only communicates with the layer directly below it. Business logic lives in services, not in route handlers or repositories.
**The rule:** each layer only talks to the layer directly below it. A controller never touches the database. A service never reads `req.body`. A model never knows what a quiz is.
### Monorepo Package Responsibilities
| Package | Owns |
| ----------------- | -------------------------------------------------------- |
| `packages/shared` | Zod schemas, constants, derived TypeScript types |
| `packages/db` | Drizzle schema, DB connection, all model/query functions |
| `apps/api` | Router, controllers, services, error handling |
| `apps/web` | React frontend, consumes types from shared |
**Key principle:** all database code lives in `packages/db`. `apps/api` never imports `drizzle-orm` for queries — it only calls functions exported from `packages/db`.
---
## 5. Infrastructure
## 7. Data Model (Current State)
### Domain structure
Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks). Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`.
| Subdomain | Service |
| --------------------- | ----------------------- |
| `app.yourdomain.com` | React frontend |
| `api.yourdomain.com` | Express API + WebSocket |
| `auth.yourdomain.com` | OpenAuth service |
**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `categories`, `term_categories`
### Docker Compose services (production)
Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports)
| Container | Role |
| ---------------- | ------------------------------------------- |
| `postgres` | PostgreSQL 16, named volume |
| `valkey` | Valkey 8, ephemeral (no persistence needed) |
| `openauth` | OpenAuth service |
| `api` | Express + WS server |
| `web` | Nginx serving the Vite build |
| `nginx-proxy` | Automatic reverse proxy |
| `acme-companion` | Let's Encrypt certificate automation |
Key columns on `translations`: `id`, `term_id` (FK), `language_code` (CHECK-constrained), `text`, `cefr_level` (nullable varchar(2), CHECK A1C2)
```docker
nginx-proxy (:80/:443)
app.domain → web:80
api.domain → api:3000 (HTTP + WS upgrade)
auth.domain → openauth:3001
```
Deck model uses `source_language` + `validated_languages` array — one deck serves multiple target languages. Decks are frequency tiers (e.g. `en-core-1000`), not POS splits.
SSL is fully automatic via `nginx-proxy` + `acme-companion`. No manual Certbot needed.
### 5.1 Valkey Key Structure
Ephemeral room state is stored in Valkey with TTL (e.g., 1 hour).
PostgreSQL stores durable history only.
Key Format: `room:{code}:{field}`
| Key | Type | TTL | Description |
|------------------------------|---------|-------|-------------|
| `room:{code}:state` | Hash | 1h | Current question index, round status |
| `room:{code}:players` | Set | 1h | List of connected user IDs |
| `room:{code}:answers:{round}`| Hash | 15m | Temp storage for current round answers |
Recovery Strategy
If server crashes mid-game, Valkey data is lost.
PostgreSQL `room_players.score` remains 0.
Room status is reset to `finished` via startup health check if `updated_at` is stale.
Full schema is in `packages/db/src/db/schema.ts`.
---
## 6. Data Model
## 8. API
## Design principle
Words are modelled as language-neutral concepts (terms) separate from learning curricula (decks).
Adding a new language pair requires no schema changes — only new rows in `translations`, `decks`, and `language_pairs`.
## Core tables
terms
id uuid PK
synset_id text UNIQUE -- OMW ILI (e.g. "ili:i12345")
pos varchar(20) -- NOT NULL, CHECK (pos IN ('noun', 'verb', 'adjective', 'adverb'))
created_at timestamptz DEFAULT now()
-- REMOVED: frequency_rank (handled at deck level)
translations
id uuid PK
term_id uuid FK → terms.id
language_code varchar(10) -- NOT NULL, BCP 47: "en", "it"
text text -- NOT NULL
created_at timestamptz DEFAULT now()
UNIQUE (term_id, language_code, text) -- Allow synonyms, prevent exact duplicates
term_glosses
id uuid PK
term_id uuid FK → terms.id
language_code varchar(10) -- NOT NULL
text text -- NOT NULL
created_at timestamptz DEFAULT now()
language_pairs
id uuid PK
source varchar(10) -- NOT NULL
target varchar(10) -- NOT NULL
label text
active boolean DEFAULT true
UNIQUE (source, target)
decks
id uuid PK
name text -- NOT NULL (e.g. "A1 Italian Nouns", "Most Common 1000")
description text -- NULLABLE
pair_id uuid FK → language_pairs.id -- NULLABLE (for single-language or multi-pair decks)
created_by uuid FK → users.id -- NULLABLE (for system decks)
is_public boolean DEFAULT true
created_at timestamptz DEFAULT now()
deck_terms
deck_id uuid FK → decks.id
term_id uuid FK → terms.id
position smallint -- NOT NULL, ordering within deck (1, 2, 3...)
added_at timestamptz DEFAULT now()
PRIMARY KEY (deck_id, term_id)
users
id uuid PK -- Internal stable ID (FK target)
openauth_sub text UNIQUE -- NOT NULL, OpenAuth `sub` claim (e.g. "google|12345")
email varchar(255) UNIQUE -- NULLABLE (GitHub users may lack email)
display_name varchar(100)
created_at timestamptz DEFAULT now()
last_login_at timestamptz
-- REMOVED: games_played, games_won (derive from room_players)
rooms
id uuid PK
code varchar(8) UNIQUE -- NOT NULL, CHECK (code = UPPER(code))
host_id uuid FK → users.id
pair_id uuid FK → language_pairs.id
deck_id uuid FK → decks.id -- Which vocabulary deck this room uses
status varchar(20) -- NOT NULL, CHECK (status IN ('waiting', 'in_progress', 'finished'))
max_players smallint -- NOT NULL, DEFAULT 4, CHECK (max_players BETWEEN 2 AND 10)
round_count smallint -- NOT NULL, DEFAULT 10, CHECK (round_count BETWEEN 5 AND 20)
created_at timestamptz DEFAULT now()
updated_at timestamptz DEFAULT now() -- For stale room recovery
room_players
room_id uuid FK → rooms.id
user_id uuid FK → users.id
score integer DEFAULT 0 -- Final score only (written at game end)
joined_at timestamptz DEFAULT now()
left_at timestamptz -- Populated on WS disconnect/leave
PRIMARY KEY (room_id, user_id)
Indexes
-- Vocabulary
CREATE INDEX idx_terms_pos ON terms (pos);
CREATE INDEX idx_translations_lang ON translations (language_code, term_id);
-- Decks
CREATE INDEX idx_decks_pair ON decks (pair_id, is_public);
CREATE INDEX idx_decks_creator ON decks (created_by);
CREATE INDEX idx_deck_terms_term ON deck_terms (term_id);
-- Language Pairs
CREATE INDEX idx_pairs_active ON language_pairs (active, source, target);
-- Rooms
CREATE INDEX idx_rooms_status ON rooms (status);
CREATE INDEX idx_rooms_host ON rooms (host_id);
-- NOTE: idx_rooms_code omitted (UNIQUE constraint creates index automatically)
-- Room Players
CREATE INDEX idx_room_players_user ON room_players (user_id);
CREATE INDEX idx_room_players_score ON room_players (room_id, score DESC);
Repository Logic Note
`DeckRepository.getTerms(deckId, limit, offset)` fetches terms from a specific deck.
Query uses `deck_terms.position` for ordering.
For random practice within a deck: `WHERE deck_id = X ORDER BY random() LIMIT N`
(safe because deck is bounded, e.g., 500 terms max, not full table).
---
## 7. Vocabulary Data — WordNet + OMW
### Source
Open Multilingual Wordnet (OMW) — English & Italian nouns via Interlingual Index (ILI)
External CEFR lists — For deck curation (e.g. GitHub: ecom/cefr-lists)
### Extraction process
1. Run `extract-en-it-nouns.py` once locally using `wn` library
- Imports ALL bilingual noun synsets (no frequency filtering)
- Output: `datafiles/en-it-nouns.json` — committed to repo
2. Run `pnpm db:seed` — populates `terms` + `translations` tables from JSON
3. Run `pnpm db:build-decks` — matches external CEFR lists to DB terms, creates `decks` + `deck_terms`
### Benefits of deck-based approach
- WordNet frequency data is unreliable (e.g. chemical symbols rank high)
- Curricula can come from external sources (CEFR, Oxford 3000, SUBTLEX)
- Bad data excluded at deck level, not schema level
- Users can create custom decks later
- Multiple difficulty levels without schema changes
`terms.synset_id` stores the OMW ILI (e.g. `ili:i12345`) for traceability and future re-imports with additional languages.
---
## 8. Authentication — OpenAuth
All auth is delegated to the OpenAuth service at `auth.yourdomain.com`. Providers: Google, GitHub.
The API validates the JWT from OpenAuth on every protected request. User rows are created or updated on first login via the `sub` claim as the primary key.
**Auth endpoint on the API:**
| Method | Path | Description |
| ------ | -------------- | --------------------------- |
| GET | `/api/auth/me` | Validate token, return user |
All other auth flows (login, callback, token refresh) are handled entirely by OpenAuth — the frontend redirects to `auth.yourdomain.com` and receives a JWT back.
---
## 9. REST API
All endpoints prefixed `/api`. Request and response bodies validated with Zod on both sides using schemas from `packages/shared`.
### Vocabulary
| Method | Path | Description |
| ------ | ---------------------------- | --------------------------------- |
| GET | `/language-pairs` | List active language pairs |
| GET | `/terms?pair=en-it&limit=10` | Fetch quiz terms with distractors |
### Rooms
| Method | Path | Description |
| ------ | ------------------- | ----------------------------------- |
| POST | `/rooms` | Create a room → returns room + code |
| GET | `/rooms/:code` | Get current room state |
| POST | `/rooms/:code/join` | Join a room |
### Users
| Method | Path | Description |
| ------ | ----------------- | ---------------------- |
| GET | `/users/me` | Current user profile |
| GET | `/users/me/stats` | Games played, win rate |
---
## 10. WebSocket Protocol
One WS connection per client. Authenticated by passing the OpenAuth JWT as a query param on the upgrade request: `wss://api.yourdomain.com?token=...`.
All messages are JSON: `{ type: string, payload: unknown }`. The full set of types is a Zod discriminated union in `packages/shared` — both sides validate every message they receive.
### Client → Server
| type | payload | Description |
| ------------- | -------------------------- | -------------------------------- |
| `room:join` | `{ code }` | Subscribe to a room's WS channel |
| `room:leave` | — | Unsubscribe |
| `room:start` | — | Host starts the game |
| `game:answer` | `{ questionId, answerId }` | Player submits an answer |
### Server → Client
| type | payload | Description |
| -------------------- | -------------------------------------------------- | ----------------------------------------- |
| `room:state` | Full room snapshot | Sent on join and on any player join/leave |
| `game:question` | `{ id, prompt, options[], timeLimit }` | New question broadcast to all players |
| `game:answer_result` | `{ questionId, correct, correctAnswerId, scores }` | Broadcast after all answer or timeout |
| `game:finished` | `{ scores[], winner }` | End of game summary |
| `error` | `{ message }` | Protocol or validation error |
### Multiplayer game mechanic — simultaneous answers
All players see the same question at the same time. Everyone submits independently. The server waits until all players have answered **or** the 15-second timeout fires — then broadcasts `game:answer_result` with updated scores. There is no buzz-first mechanic. This keeps the experience Duolingo-like and symmetric.
### Game flow
### Endpoints
```text
host creates room (REST) →
players join via room code (REST + WS room:join) →
room:state broadcasts player list →
host sends room:start →
server broadcasts game:question →
players send game:answer →
server collects all answers or waits for timeout →
server broadcasts game:answer_result →
repeat for N rounds →
server broadcasts game:finished
POST /api/v1/game/start GameRequest → GameSession
POST /api/v1/game/answer AnswerSubmission → AnswerResult
GET /api/v1/health Health check
```
### Room state in Valkey
### Schemas (packages/shared)
Active room state (connected players, current question, answers received this round) is stored in Valkey with a TTL. PostgreSQL holds the durable record (`rooms`, `room_players`). On server restart, in-progress games are considered abandoned — acceptable for MVP.
**GameRequest:** `{ source_language, target_language, pos, difficulty, rounds }`
**GameSession:** `{ sessionId: uuid, questions: GameQuestion[] }`
**GameQuestion:** `{ questionId: uuid, prompt: string, gloss: string | null, options: AnswerOption[4] }`
**AnswerOption:** `{ optionId: number (0-3), text: string }`
**AnswerSubmission:** `{ sessionId: uuid, questionId: uuid, selectedOptionId: number (0-3) }`
**AnswerResult:** `{ questionId: uuid, isCorrect: boolean, correctOptionId: number (0-3), selectedOptionId: number (0-3) }`
### Error Handling
Typed error classes (`AppError` base, `ValidationError` 400, `NotFoundError` 404) with central error middleware. Controllers validate with `safeParse`, throw on failure, and call `next(error)` in the catch. The middleware maps `AppError` instances to HTTP status codes; unknown errors return 500.
### Key Design Rules
- Server-side answer evaluation: the correct answer is never sent to the frontend
- `POST` not `GET` for game start (configuration in request body)
- `safeParse` over `parse` (clean 400s, not raw Zod 500s)
- Session state stored in `GameSessionStore` (in-memory now, Valkey later)
---
## 11. Game Mechanics
## 9. Game Mechanics
- **Question format**: source-language word prompt + 4 target-language choices (1 correct + 3 distractors of the same POS)
- **Distractors**: generated server-side, never include the correct answer, never repeat within a session
- **Scoring**: +1 point per correct answer. Speed bonus is out of scope for MVP.
- **Timer**: 15 seconds per question, server-authoritative
- **Single-player**: uses `GET /terms` and runs entirely client-side. No WebSocket.
- **Format**: source-language word prompt + 4 target-language choices
- **Distractors**: same POS, same difficulty, server-side, never the correct answer, never repeated within a session
- **Session length**: 3 or 10 questions (configurable)
- **Scoring**: +1 per correct answer (no speed bonus for MVP)
- **Timer**: none in singleplayer MVP
- **No auth required**: anonymous users
- **Submit-before-send**: user selects, then confirms (prevents misclicks)
---
## 12. Frontend Structure
## 10. Working Methodology
```tree
apps/web/src/
├── routes/
│ ├── index.tsx # Landing / mode select
│ ├── auth/
│ ├── singleplayer/
│ └── multiplayer/
│ ├── lobby.tsx # Create or join by code
│ ├── room.$code.tsx # Waiting room
│ └── game.$code.tsx # Active game
├── components/
│ ├── quiz/ # QuestionCard, OptionButton, ScoreBoard
│ ├── room/ # PlayerList, RoomCode, ReadyState
│ └── ui/ # shadcn/ui wrappers: Button, Card, Dialog ...
├── stores/
│ └── gameStore.ts # Zustand: game session, scores, WS state
├── lib/
│ ├── api.ts # TanStack Query wrappers
│ └── ws.ts # WS client singleton
└── main.tsx
This project is a learning exercise. The goal is to understand the code, not just to ship it.
### How to use an LLM for help
1. Paste this document as context
2. Describe what you're working on and what you're stuck on
3. Ask for hints, not solutions
### Refactoring workflow
After completing a task: share the code, ask what to refactor and why. The LLM should explain the concept, not write the implementation.
---
## 11. Post-MVP Ladder
| Phase | What it adds |
| ----------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Auth | Auth | Better Auth (Google + GitHub), embedded in Express API, user rows in DB |
| User Stats | Games played, score history, profile page |
| Multiplayer Lobby | Room creation, join by code, WebSocket connection |
| Multiplayer Game | Simultaneous answers, server timer, live scores, winner screen |
| Deployment | Docker Compose prod config, Nginx, Let's Encrypt, Hetzner VPS |
| Hardening | Rate limiting, error boundaries, CI/CD, DB backups |
### Future Data Model Extensions (deferred, additive)
- `noun_forms` — gender, singular, plural, articles per language
- `verb_forms` — conjugation tables per language
- `term_pronunciations` — IPA and audio URLs per language
- `user_decks` — which decks a user is studying
- `user_term_progress` — spaced repetition state per user/term/language
- `quiz_answers` — history log for stats
All are new tables referencing existing `terms` rows via FK. No existing schema changes required.
### Multiplayer Architecture (deferred)
- WebSocket protocol: `ws` library, Zod discriminated union for message types
- Room model: human-readable codes (e.g. `WOLF-42`), not matchmaking queue
- Game mechanic: simultaneous answers, 15-second server timer, all players see same question
- Valkey for ephemeral room state, PostgreSQL for durable records
### Infrastructure (deferred)
- `app.yourdomain.com` → React frontend
- `api.yourdomain.com` → Express API + WebSocket + Better Auth
- Docker Compose with `nginx-proxy` + `acme-companion` for automatic SSL
---
## 12. Definition of Done (MVP)
- [x] API returns quiz terms with correct distractors
- [x] User can complete a quiz without errors
- [x] Score screen shows final result and a play-again option
- [x] App is usable on a mobile screen
- [x] No hardcoded data — everything comes from the database
- [x] Global error handler with typed error classes
- [x] Unit + integration tests for API
---
## 13. Roadmap
See `roadmap.md` for the full roadmap with task-level checkboxes.
### Dependency Graph
```text
Phase 0 (Foundation)
└── Phase 1 (Vocabulary Data + API)
└── Phase 2 (Singleplayer UI)
└── Phase 3 (Auth)
├── Phase 4 (Room Lobby)
│ └── Phase 5 (Multiplayer Game)
│ └── Phase 6 (Deployment)
└── Phase 7 (Hardening)
```
### Zustand store (single store for MVP)
```typescript
interface AppStore {
user: User | null;
gameSession: GameSession | null;
currentQuestion: Question | null;
scores: Record<string, number>;
isLoading: boolean;
error: string | null;
}
```
TanStack Query handles all server data fetching. Zustand handles ephemeral UI and WebSocket-driven state.
---
## 13. Testing Strategy
## 14. Game Flow (Future)
| Type | Tool | Scope |
| ----------- | -------------------- | --------------------------------------------------- |
| Unit | Vitest | Services, QuizService distractor logic, Zod schemas |
| Component | Vitest + RTL | QuestionCard, OptionButton, auth forms |
| Integration | Vitest | API route handlers against a test DB |
| E2E | Out of scope for MVP | — |
Singleplayer: choose direction (en→it or it→en) → top-level category → part of speech → difficulty (A1C2) → round count → game starts.
Tests are co-located with source files (`*.test.ts` / `*.test.tsx`).
**Top-level categories (post-MVP):**
**Critical paths to cover:**
- Distractor generation (correct POS, no duplicates, never includes answer)
- Answer validation (server-side, correct scoring)
- Game session lifecycle (create → play → complete)
- JWT validation middleware
---
## 14. Definition of Done
### Functional
- [ ] User can log in via Google or GitHub (OpenAuth)
- [ ] User can play singleplayer: 10 rounds, score, result screen
- [ ] User can create a room and share a code
- [ ] User can join a room via code
- [ ] Multiplayer: 10 rounds, simultaneous answers, real-time score sync
- [ ] 1 000 EnglishItalian words seeded from WordNet + OMW
### Technical
- [ ] Deployed to Hetzner with HTTPS on all three subdomains
- [ ] Docker Compose running all services
- [ ] Drizzle migrations applied on container start
- [ ] 1020 passing tests covering critical paths
- [ ] pnpm workspace build pipeline green
---
## 15. Out of Scope (MVP)
- Difficulty levels _(`frequency_rank` column exists, ready to use)_
- Additional language pairs _(schema already supports it — just add rows)_
- Leaderboards _(`games_played`, `games_won` columns exist)_
- Streaks / daily challenges
- Friends / private invites
- Audio pronunciation
- CI/CD pipeline (manual deploy for now)
- Rate limiting _(add before going public)_
- Admin panel for vocabulary management
- **Grammar** — practice nouns, verb conjugations, etc.
- **Media** — practice vocabulary from specific books, films, songs, etc.
- **Thematic** — animals, kitchen, etc. (requires category metadata research)

View file

@ -1,11 +1,11 @@
{
"name": "glossa",
"name": "lila",
"version": "1.0.0",
"description": "a vocabulary trainer",
"private": true,
"scripts": {
"build": "pnpm --filter @glossa/shared build && pnpm --filter @glossa/db build",
"dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @glossa/api dev\" \"pnpm --filter @glossa/web dev\"",
"build": "pnpm --filter @lila/shared build && pnpm --filter @lila/db build && pnpm --filter @lila/api build",
"dev": "concurrently --names \"api,web\" -c \"magenta.bold,green.bold\" \"pnpm --filter @lila/api dev\" \"pnpm --filter @lila/web dev\"",
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint .",

View file

@ -0,0 +1,55 @@
CREATE TABLE "account" (
"id" text PRIMARY KEY NOT NULL,
"account_id" text NOT NULL,
"provider_id" text NOT NULL,
"user_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"id_token" text,
"access_token_expires_at" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"image" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "term_glosses" DROP CONSTRAINT "unique_term_gloss";--> statement-breakpoint
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint
ALTER TABLE "term_glosses" ADD CONSTRAINT "unique_term_gloss" UNIQUE("term_id","language_code");

View file

@ -0,0 +1 @@
DROP TABLE "users" CASCADE;

View file

@ -0,0 +1,941 @@
{
"id": "6455ad81-98c0-4f32-a2fa-0f99ce9ce8e5",
"prevId": "8b22765b-67bb-4bc3-9549-4206ca080343",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.deck_terms": {
"name": "deck_terms",
"schema": "",
"columns": {
"deck_id": {
"name": "deck_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"deck_terms_deck_id_decks_id_fk": {
"name": "deck_terms_deck_id_decks_id_fk",
"tableFrom": "deck_terms",
"tableTo": "decks",
"columnsFrom": ["deck_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_terms_term_id_terms_id_fk": {
"name": "deck_terms_term_id_terms_id_fk",
"tableFrom": "deck_terms",
"tableTo": "terms",
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_terms_deck_id_term_id_pk": {
"name": "deck_terms_deck_id_term_id_pk",
"columns": ["deck_id", "term_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.decks": {
"name": "decks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_language": {
"name": "source_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"validated_languages": {
"name": "validated_languages",
"type": "varchar(10)[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"type": {
"name": "type",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_decks_type": {
"name": "idx_decks_type",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "source_language",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_deck_name": {
"name": "unique_deck_name",
"nullsNotDistinct": false,
"columns": ["name", "source_language"]
}
},
"policies": {},
"checkConstraints": {
"source_language_check": {
"name": "source_language_check",
"value": "\"decks\".\"source_language\" IN ('en', 'it')"
},
"validated_languages_check": {
"name": "validated_languages_check",
"value": "validated_languages <@ ARRAY['en', 'it']::varchar[]"
},
"validated_languages_excludes_source": {
"name": "validated_languages_excludes_source",
"value": "NOT (\"decks\".\"source_language\" = ANY(\"decks\".\"validated_languages\"))"
},
"deck_type_check": {
"name": "deck_type_check",
"value": "\"decks\".\"type\" IN ('grammar', 'media')"
}
},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.term_glosses": {
"name": "term_glosses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language_code": {
"name": "language_code",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"term_glosses_term_id_terms_id_fk": {
"name": "term_glosses_term_id_terms_id_fk",
"tableFrom": "term_glosses",
"tableTo": "terms",
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_term_gloss": {
"name": "unique_term_gloss",
"nullsNotDistinct": false,
"columns": ["term_id", "language_code"]
}
},
"policies": {},
"checkConstraints": {
"language_code_check": {
"name": "language_code_check",
"value": "\"term_glosses\".\"language_code\" IN ('en', 'it')"
}
},
"isRLSEnabled": false
},
"public.term_topics": {
"name": "term_topics",
"schema": "",
"columns": {
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"topic_id": {
"name": "topic_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"term_topics_term_id_terms_id_fk": {
"name": "term_topics_term_id_terms_id_fk",
"tableFrom": "term_topics",
"tableTo": "terms",
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"term_topics_topic_id_topics_id_fk": {
"name": "term_topics_topic_id_topics_id_fk",
"tableFrom": "term_topics",
"tableTo": "topics",
"columnsFrom": ["topic_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"term_topics_term_id_topic_id_pk": {
"name": "term_topics_term_id_topic_id_pk",
"columns": ["term_id", "topic_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.terms": {
"name": "terms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pos": {
"name": "pos",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_terms_source_pos": {
"name": "idx_terms_source_pos",
"columns": [
{
"expression": "source",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "pos",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_source_id": {
"name": "unique_source_id",
"nullsNotDistinct": false,
"columns": ["source", "source_id"]
}
},
"policies": {},
"checkConstraints": {
"pos_check": {
"name": "pos_check",
"value": "\"terms\".\"pos\" IN ('noun', 'verb')"
}
},
"isRLSEnabled": false
},
"public.topics": {
"name": "topics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"topics_slug_unique": {
"name": "topics_slug_unique",
"nullsNotDistinct": false,
"columns": ["slug"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.translations": {
"name": "translations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language_code": {
"name": "language_code",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cefr_level": {
"name": "cefr_level",
"type": "varchar(2)",
"primaryKey": false,
"notNull": false
},
"difficulty": {
"name": "difficulty",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_translations_lang": {
"name": "idx_translations_lang",
"columns": [
{
"expression": "language_code",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "difficulty",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "cefr_level",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "term_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"translations_term_id_terms_id_fk": {
"name": "translations_term_id_terms_id_fk",
"tableFrom": "translations",
"tableTo": "terms",
"columnsFrom": ["term_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_translations": {
"name": "unique_translations",
"nullsNotDistinct": false,
"columns": ["term_id", "language_code", "text"]
}
},
"policies": {},
"checkConstraints": {
"language_code_check": {
"name": "language_code_check",
"value": "\"translations\".\"language_code\" IN ('en', 'it')"
},
"cefr_check": {
"name": "cefr_check",
"value": "\"translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')"
},
"difficulty_check": {
"name": "difficulty_check",
"value": "\"translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')"
}
},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"openauth_sub": {
"name": "openauth_sub",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"display_name": {
"name": "display_name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_openauth_sub_unique": {
"name": "users_openauth_sub_unique",
"nullsNotDistinct": false,
"columns": ["openauth_sub"]
},
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
},
"users_display_name_unique": {
"name": "users_display_name_unique",
"nullsNotDistinct": false,
"columns": ["display_name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}

View file

@ -0,0 +1,935 @@
{
"id": "8f34bafa-cffc-4933-952f-64b46afa9c5c",
"prevId": "6455ad81-98c0-4f32-a2fa-0f99ce9ce8e5",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_userId_idx": {
"name": "account_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.deck_terms": {
"name": "deck_terms",
"schema": "",
"columns": {
"deck_id": {
"name": "deck_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"deck_terms_deck_id_decks_id_fk": {
"name": "deck_terms_deck_id_decks_id_fk",
"tableFrom": "deck_terms",
"tableTo": "decks",
"columnsFrom": [
"deck_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deck_terms_term_id_terms_id_fk": {
"name": "deck_terms_term_id_terms_id_fk",
"tableFrom": "deck_terms",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"deck_terms_deck_id_term_id_pk": {
"name": "deck_terms_deck_id_term_id_pk",
"columns": [
"deck_id",
"term_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.decks": {
"name": "decks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_language": {
"name": "source_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"validated_languages": {
"name": "validated_languages",
"type": "varchar(10)[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"type": {
"name": "type",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_decks_type": {
"name": "idx_decks_type",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "source_language",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_deck_name": {
"name": "unique_deck_name",
"nullsNotDistinct": false,
"columns": [
"name",
"source_language"
]
}
},
"policies": {},
"checkConstraints": {
"source_language_check": {
"name": "source_language_check",
"value": "\"decks\".\"source_language\" IN ('en', 'it')"
},
"validated_languages_check": {
"name": "validated_languages_check",
"value": "validated_languages <@ ARRAY['en', 'it']::varchar[]"
},
"validated_languages_excludes_source": {
"name": "validated_languages_excludes_source",
"value": "NOT (\"decks\".\"source_language\" = ANY(\"decks\".\"validated_languages\"))"
},
"deck_type_check": {
"name": "deck_type_check",
"value": "\"decks\".\"type\" IN ('grammar', 'media')"
}
},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"session_userId_idx": {
"name": "session_userId_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.term_glosses": {
"name": "term_glosses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language_code": {
"name": "language_code",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"term_glosses_term_id_terms_id_fk": {
"name": "term_glosses_term_id_terms_id_fk",
"tableFrom": "term_glosses",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_term_gloss": {
"name": "unique_term_gloss",
"nullsNotDistinct": false,
"columns": [
"term_id",
"language_code"
]
}
},
"policies": {},
"checkConstraints": {
"language_code_check": {
"name": "language_code_check",
"value": "\"term_glosses\".\"language_code\" IN ('en', 'it')"
}
},
"isRLSEnabled": false
},
"public.term_topics": {
"name": "term_topics",
"schema": "",
"columns": {
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"topic_id": {
"name": "topic_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"term_topics_term_id_terms_id_fk": {
"name": "term_topics_term_id_terms_id_fk",
"tableFrom": "term_topics",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"term_topics_topic_id_topics_id_fk": {
"name": "term_topics_topic_id_topics_id_fk",
"tableFrom": "term_topics",
"tableTo": "topics",
"columnsFrom": [
"topic_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"term_topics_term_id_topic_id_pk": {
"name": "term_topics_term_id_topic_id_pk",
"columns": [
"term_id",
"topic_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.terms": {
"name": "terms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"source": {
"name": "source",
"type": "varchar(50)",
"primaryKey": false,
"notNull": false
},
"source_id": {
"name": "source_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pos": {
"name": "pos",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_terms_source_pos": {
"name": "idx_terms_source_pos",
"columns": [
{
"expression": "source",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "pos",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_source_id": {
"name": "unique_source_id",
"nullsNotDistinct": false,
"columns": [
"source",
"source_id"
]
}
},
"policies": {},
"checkConstraints": {
"pos_check": {
"name": "pos_check",
"value": "\"terms\".\"pos\" IN ('noun', 'verb')"
}
},
"isRLSEnabled": false
},
"public.topics": {
"name": "topics",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"topics_slug_unique": {
"name": "topics_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.translations": {
"name": "translations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"term_id": {
"name": "term_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language_code": {
"name": "language_code",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"text": {
"name": "text",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cefr_level": {
"name": "cefr_level",
"type": "varchar(2)",
"primaryKey": false,
"notNull": false
},
"difficulty": {
"name": "difficulty",
"type": "varchar(20)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_translations_lang": {
"name": "idx_translations_lang",
"columns": [
{
"expression": "language_code",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "difficulty",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "cefr_level",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "term_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"translations_term_id_terms_id_fk": {
"name": "translations_term_id_terms_id_fk",
"tableFrom": "translations",
"tableTo": "terms",
"columnsFrom": [
"term_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_translations": {
"name": "unique_translations",
"nullsNotDistinct": false,
"columns": [
"term_id",
"language_code",
"text"
]
}
},
"policies": {},
"checkConstraints": {
"language_code_check": {
"name": "language_code_check",
"value": "\"translations\".\"language_code\" IN ('en', 'it')"
},
"cefr_check": {
"name": "cefr_check",
"value": "\"translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')"
},
"difficulty_check": {
"name": "difficulty_check",
"value": "\"translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')"
}
},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -29,6 +29,20 @@
"when": 1775513042249,
"tag": "0003_greedy_revanche",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1775986238669,
"tag": "0004_red_annihilus",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1776154563168,
"tag": "0005_broad_mariko_yashida",
"breakpoints": true
}
]
}
}

View file

@ -1,5 +1,5 @@
{
"name": "@glossa/db",
"name": "@lila/db",
"version": "1.0.0",
"private": true,
"type": "module",
@ -11,7 +11,7 @@
"db:build-deck": "npx tsx src/generating-deck.ts"
},
"dependencies": {
"@glossa/shared": "workspace:*",
"@lila/shared": "workspace:*",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"pg": "^8.20.0",
@ -22,7 +22,7 @@
"drizzle-kit": "^0.31.10"
},
"exports": {
".": "./src/index.ts",
"./schema": "./src/db/schema.ts"
".": "./dist/src/index.js",
"./schema": "./dist/src/db/schema.js"
}
}

View file

@ -21,9 +21,9 @@ import {
SUPPORTED_POS,
CEFR_LEVELS,
DIFFICULTY_LEVELS,
} from "@glossa/shared";
import { db } from "@glossa/db";
import { terms, translations } from "@glossa/db/schema";
} from "@lila/shared";
import { db } from "@lila/db";
import { terms, translations } from "@lila/db/schema";
type POS = (typeof SUPPORTED_POS)[number];
type LanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
@ -165,7 +165,7 @@ async function checkCoverage(language: LanguageCode): Promise<void> {
const main = async () => {
console.log("##########################################");
console.log("Glossa — CEFR Coverage Check");
console.log("lila — CEFR Coverage Check");
console.log("##########################################");
for (const language of SUPPORTED_LANGUAGE_CODES) {

View file

@ -8,9 +8,10 @@ import {
check,
primaryKey,
index,
boolean,
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { sql, relations } from "drizzle-orm";
import {
SUPPORTED_POS,
@ -18,7 +19,7 @@ import {
CEFR_LEVELS,
SUPPORTED_DECK_TYPES,
DIFFICULTY_LEVELS,
} from "@glossa/shared";
} from "@lila/shared";
export const terms = pgTable(
"terms",
@ -99,20 +100,6 @@ export const translations = pgTable(
],
);
export const users = pgTable("users", {
id: uuid().primaryKey().defaultRandom(),
openauth_sub: text().unique().notNull(),
email: varchar({ length: 255 }).unique(),
display_name: varchar({ length: 100 }).unique(),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
last_login_at: timestamp({ withTimezone: true }),
});
// KNOWN LIMITATION: email is nullable (GitHub users may have no public email)
// and unique, but two OAuth providers can return the same email for different
// accounts. For MVP this is acceptable since users are identified by
// openauth_sub, not email. If multi-provider login per user is added later,
// consider a separate user_emails table.
export const decks = pgTable(
"decks",
{
@ -180,6 +167,91 @@ export const term_topics = pgTable(
(table) => [primaryKey({ columns: [table.term_id, table.topic_id] })],
);
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, { fields: [session.userId], references: [user.id] }),
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, { fields: [account.userId], references: [user.id] }),
}));
/*
* INTENTIONAL DESIGN DECISIONS see decisions.md for full reasoning
*

View file

@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import { db } from "@glossa/db";
import { translations, terms, decks, deck_terms } from "@glossa/db/schema";
import { db } from "@lila/db";
import { translations, terms, decks, deck_terms } from "@lila/db/schema";
import { inArray, and, eq, ne, countDistinct } from "drizzle-orm";
type DbOrTx = Parameters<Parameters<typeof db.transaction>[0]>[0];

View file

@ -1,13 +1,13 @@
import { db } from "@glossa/db";
import { db } from "@lila/db";
import { eq, and, isNotNull, sql, ne } from "drizzle-orm";
import { terms, translations, term_glosses } from "@glossa/db/schema";
import { terms, translations, term_glosses } from "@lila/db/schema";
import { alias } from "drizzle-orm/pg-core";
import type {
SupportedLanguageCode,
SupportedPos,
DifficultyLevel,
} from "@glossa/shared";
} from "@lila/shared";
export type TranslationPairRow = {
termId: string;

View file

@ -6,9 +6,9 @@ import {
SUPPORTED_POS,
CEFR_LEVELS,
DIFFICULTY_LEVELS,
} from "@glossa/shared";
import { db } from "@glossa/db";
import { translations, terms } from "@glossa/db/schema";
} from "@lila/shared";
import { db } from "@lila/db";
import { translations, terms } from "@lila/db/schema";
type POS = (typeof SUPPORTED_POS)[number];
type LanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
@ -130,7 +130,7 @@ async function enrichLanguage(language: LanguageCode): Promise<void> {
const main = async () => {
console.log("##########################################");
console.log("Glossa — CEFR Enrichment");
console.log("lila — CEFR Enrichment");
console.log("##########################################\n");
for (const lang of SUPPORTED_LANGUAGE_CODES) {

View file

@ -1,9 +1,9 @@
import fs from "node:fs/promises";
import { and, count, eq, inArray } from "drizzle-orm";
import { SUPPORTED_LANGUAGE_CODES, SUPPORTED_POS } from "@glossa/shared";
import { db } from "@glossa/db";
import { terms, translations, term_glosses } from "@glossa/db/schema";
import { SUPPORTED_LANGUAGE_CODES, SUPPORTED_POS } from "@lila/shared";
import { db } from "@lila/db";
import { terms, translations, term_glosses } from "@lila/db/schema";
type POS = (typeof SUPPORTED_POS)[number];
type LanguageCode = (typeof SUPPORTED_LANGUAGE_CODES)[number];
@ -129,7 +129,7 @@ async function processBatch(batch: SynsetRecord[]): Promise<void> {
const main = async () => {
console.log("\n##########################################");
console.log("Glossa — OMW seed");
console.log("lila — OMW seed");
console.log("##########################################\n");
// One file per POS — names are derived from SUPPORTED_POS so adding a new

View file

@ -1,5 +1,5 @@
{
"name": "@glossa/shared",
"name": "@lila/shared",
"version": "1.0.0",
"private": true,
"type": "module",
@ -7,7 +7,7 @@
"build": "tsc"
},
"exports": {
".": "./src/index.ts"
".": "./dist/src/index.js"
},
"dependencies": {
"zod": "^4.3.6"

646
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@ This directory contains the source data files and extraction/merge pipeline for
## Overview
The pipeline transforms raw vocabulary data from multiple sources into a standardized format, resolves conflicts between sources, and produces an authoritative CEFR dataset per language. This dataset is then used by the Glossa database package to update translation records.
The pipeline transforms raw vocabulary data from multiple sources into a standardized format, resolves conflicts between sources, and produces an authoritative CEFR dataset per language. This dataset is then used by the lila database package to update translation records.
## Supported Languages
@ -195,7 +195,7 @@ To add a new source:
## Constants and Constraints
The pipeline respects these constraints from the Glossa shared constants:
The pipeline respects these constraints from the lila shared constants:
- **Supported languages:** en, it
- **Supported parts of speech:** noun, verb

View file

@ -18,7 +18,7 @@ import csv
import json
from pathlib import Path
# Constants matching @glossa/shared
# Constants matching @lila/shared
SUPPORTED_POS = ["noun", "verb"]
CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]

View file

@ -10,7 +10,7 @@ from pathlib import Path
import xlrd
# Constants matching @glossa/shared
# Constants matching @lila/shared
SUPPORTED_POS = ["noun", "verb"]
CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]

View file

@ -15,7 +15,7 @@ import csv
import json
from pathlib import Path
# Constants matching @glossa/shared
# Constants matching @lila/shared
SUPPORTED_POS = ["noun", "verb"]
CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]

View file

@ -17,7 +17,7 @@ Output format (normalized):
import json
from pathlib import Path
# Constants matching @glossa/shared
# Constants matching @lila/shared
SUPPORTED_POS = ["noun", "verb"]
CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]

View file

@ -34,6 +34,25 @@ async function main() {
`${question.prompt}: ${result.data.isCorrect ? "✓" : "✗"} (picked ${0}, correct was ${result.data.correctOptionId})`,
);
}
const badRequest = await fetch("http://localhost:3000/api/v1/game/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_language: "en" }),
});
console.log("400 test:", badRequest.status, await badRequest.json());
// Send a valid shape but a session that doesn't exist
const notFound = await fetch("http://localhost:3000/api/v1/game/answer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId: "00000000-0000-0000-0000-000000000000",
questionId: "00000000-0000-0000-0000-000000000000",
selectedOptionId: 0,
}),
});
console.log("404 test:", notFound.status, await notFound.json());
}
main();

420377
seed.sql Normal file

File diff suppressed because it is too large Load diff