Compare commits

..

No commits in common. "927ec14e2d67fb9e5fc9d055b5d5ea29304d4478" and "94f02b99049bad6e7d3f2e54cf975c67fb432b29" have entirely different histories.

150 changed files with 471 additions and 6667767 deletions

View file

@ -1,11 +0,0 @@
**/node_modules
**/dist
**/build
**/coverage
.env
*.log
npm-debug.log*
.git
.gitignore
*.tsbuildinfo

View file

@ -1,12 +0,0 @@
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=

View file

@ -1,44 +0,0 @@
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: docker
steps:
- name: Checkout code
uses: https://data.forgejo.org/actions/checkout@v4
- name: Login to registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.lilastudy.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Build API image
run: |
docker build \
-t git.lilastudy.com/forgejo-lila/lila-api:latest \
--target runner \
-f apps/api/Dockerfile .
- name: Build Web image
run: |
docker build \
-t git.lilastudy.com/forgejo-lila/lila-web:latest \
--target production \
--build-arg VITE_API_URL=https://api.lilastudy.com \
-f apps/web/Dockerfile .
- name: Push images
run: |
docker push git.lilastudy.com/forgejo-lila/lila-api:latest
docker push git.lilastudy.com/forgejo-lila/lila-web:latest
- name: Deploy via SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -o StrictHostKeyChecking=no ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} \
"cd ~/lila-app && docker compose pull api web && docker compose up -d api web && docker image prune -f"

11
.gitignore vendored
View file

@ -1,11 +0,0 @@
node_modules/
dist/
build/
.env
**/*.tsbuildinfo
.repomixignore
repomix.config.json
repomix/
venv/
__pycache__/
*.pyc

View file

@ -1,20 +0,0 @@
.tmp/
# Build outputs
dist/
*.tsbuildinfo
# Dependencies
node_modules/
# Environment files
.env*
# Logs (if you create them)
logs/
# Coverage reports (when you add testing)
coverage/
pnpm-lock.yaml
routeTree.gen.ts

View file

@ -1,13 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "all",
"bracketSpacing": true,
"objectWrap": "collapse",
"bracketSameLine": false,
"arrowParens": "always"
}

View file

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

View file

@ -1 +1 @@
# lila
# glossa

View file

@ -1,44 +0,0 @@
# 1. select image and install pnpm
FROM node:24-alpine AS base
RUN npm install -g pnpm
# 2. dependencies
FROM base AS deps
WORKDIR /app
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/
RUN pnpm install --frozen-lockfile
# 3. Development (only stage used)
FROM base AS dev
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . ./
EXPOSE 3000
CMD ["pnpm", "--filter", "api", "dev"]
# 4. build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm install
RUN pnpm --filter shared build
RUN pnpm --filter db build
RUN pnpm --filter api build
# 5. run
FROM base AS runner
WORKDIR /app
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", "apps/api/dist/src/server.js"]

View file

@ -1,26 +0,0 @@
{
"name": "@lila/api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/src/server.js",
"test": "vitest"
},
"dependencies": {
"@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,24 +0,0 @@
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

@ -1,136 +0,0 @@
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,42 +0,0 @@
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,
next: NextFunction,
) => {
try {
const gameSettings = GameRequestSchema.safeParse(req.body);
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);
}
};
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) {
next(error);
}
};

View file

@ -1,21 +0,0 @@
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);
}
}

View file

@ -1,7 +0,0 @@
export type GameSessionData = { answers: Map<string, number> };
export interface GameSessionStore {
create(sessionId: string, data: GameSessionData): Promise<void>;
get(sessionId: string): Promise<GameSessionData | null>;
delete(sessionId: string): Promise<void>;
}

View file

@ -1,17 +0,0 @@
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
export class InMemoryGameSessionStore implements GameSessionStore {
private sessions = new Map<string, GameSessionData>();
async create(sessionId: string, data: GameSessionData): Promise<void> {
this.sessions.set(sessionId, data);
}
async get(sessionId: string): Promise<GameSessionData | null> {
return this.sessions.get(sessionId) ?? null;
}
async delete(sessionId: string): Promise<void> {
this.sessions.delete(sessionId);
}
}

View file

@ -1,2 +0,0 @@
export type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
export { InMemoryGameSessionStore } from "./InMemoryGameSessionStore.js";

View file

@ -1,30 +0,0 @@
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

@ -1,20 +0,0 @@
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

@ -1,18 +0,0 @@
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,9 +0,0 @@
import express from "express";
import { Router } from "express";
import { healthRouter } from "./healthRouter.js";
import { gameRouter } from "./gameRouter.js";
export const apiRouter: Router = express.Router();
apiRouter.use("/health", healthRouter);
apiRouter.use("/game", gameRouter);

View file

@ -1,10 +0,0 @@
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

@ -1,11 +0,0 @@
import type { Request, Response } from "express";
export const healthRouter = (_req: Request, res: Response) => {
res
.status(200)
.json({
status: "ok",
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
};

View file

@ -1,9 +0,0 @@
import { createApp } from "./app.js";
const PORT = Number(process.env["PORT"] ?? 3000);
const app = createApp();
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});

View file

@ -1,192 +0,0 @@
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,99 +0,0 @@
import { randomUUID } from "crypto";
import { getGameTerms, getDistractors } from "@lila/db";
import type {
GameRequest,
GameSession,
GameQuestion,
AnswerOption,
AnswerSubmission,
AnswerResult,
} from "@lila/shared";
import { InMemoryGameSessionStore } from "../gameSessionStore/index.js";
import { NotFoundError } from "../errors/AppError.js";
const gameSessionStore = new InMemoryGameSessionStore();
export const createGameSession = async (
request: GameRequest,
): Promise<GameSession> => {
const correctAnswers = await getGameTerms(
request.source_language,
request.target_language,
request.pos,
request.difficulty,
Number(request.rounds),
);
const answerKey = new Map<string, number>();
const questions: GameQuestion[] = await Promise.all(
correctAnswers.map(async (correctAnswer) => {
const distractorTexts = await getDistractors(
correctAnswer.termId,
correctAnswer.targetText,
request.target_language,
request.pos,
request.difficulty,
3,
);
const optionTexts = [correctAnswer.targetText, ...distractorTexts];
const shuffledTexts = shuffle(optionTexts);
const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText);
const options: AnswerOption[] = shuffledTexts.map((text, index) => ({
optionId: index,
text,
}));
const questionId = randomUUID();
answerKey.set(questionId, correctOptionId);
return {
questionId,
prompt: correctAnswer.sourceText,
gloss: correctAnswer.sourceGloss,
options,
};
}),
);
const sessionId = randomUUID();
await gameSessionStore.create(sessionId, { answers: answerKey });
return { sessionId, questions };
};
const shuffle = <T>(array: T[]): T[] => {
const result = [...array];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = result[i]!;
result[i] = result[j]!;
result[j] = temp;
}
return result;
};
export const evaluateAnswer = async (
submission: AnswerSubmission,
): Promise<AnswerResult> => {
const session = await gameSessionStore.get(submission.sessionId);
if (!session) {
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
}
const correctOptionId = session.answers.get(submission.questionId);
if (correctOptionId === undefined) {
throw new NotFoundError(`Question not found: ${submission.questionId}`);
}
return {
questionId: submission.questionId,
isCorrect: submission.selectedOptionId === correctOptionId,
correctOptionId,
selectedOptionId: submission.selectedOptionId,
};
};

View file

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

View file

@ -1,3 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "node", globals: true } });

24
apps/web/.gitignore vendored
View file

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -1,36 +0,0 @@
# 1. Base
FROM node:24-alpine AS base
RUN npm install -g pnpm
# 2. Deps
FROM base AS deps
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
RUN pnpm install --frozen-lockfile
# 3. Dev
FROM base AS dev
WORKDIR /app
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

@ -1,73 +0,0 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from "eslint-plugin-react-x";
import reactDom from "eslint-plugin-react-dom";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs["recommended-typescript"],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ["./tsconfig.node.json", "./tsconfig.app.json"],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
]);
```

View file

@ -1,14 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lila</title>
<!--TODO: add favicon-->
<link rel="icon" href="data:," />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

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

View file

@ -1,30 +0,0 @@
{
"name": "@lila/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@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"
},
"devDependencies": {
"@tanstack/router-plugin": "^1.167.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"vite": "^8.0.1"
}
}

View file

@ -1,140 +0,0 @@
import { useState } from "react";
import {
SUPPORTED_LANGUAGE_CODES,
SUPPORTED_POS,
DIFFICULTY_LEVELS,
GAME_ROUNDS,
} from "@lila/shared";
import type { GameRequest } from "@lila/shared";
const LABELS: Record<string, string> = {
en: "English",
it: "Italian",
noun: "Nouns",
verb: "Verbs",
easy: "Easy",
intermediate: "Intermediate",
hard: "Hard",
"3": "3 rounds",
"10": "10 rounds",
};
type GameSetupProps = { onStart: (settings: GameRequest) => void };
type SettingGroupProps = {
label: string;
options: readonly string[];
selected: string;
onSelect: (value: string) => void;
};
const SettingGroup = ({
label,
options,
selected,
onSelect,
}: SettingGroupProps) => (
<div className="w-full">
<p className="text-sm font-medium text-purple-400 mb-2">{label}</p>
<div className="flex gap-2 flex-wrap">
{options.map((option) => (
<button
key={option}
onClick={() => onSelect(option)}
className={`py-2 px-5 rounded-xl font-semibold text-sm border-b-4 transition-all duration-200 cursor-pointer ${
selected === option
? "bg-purple-600 text-white border-purple-800"
: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300"
}`}
>
{LABELS[option] ?? option}
</button>
))}
</div>
</div>
);
export const GameSetup = ({ onStart }: GameSetupProps) => {
const [sourceLanguage, setSourceLanguage] = useState<string>(
SUPPORTED_LANGUAGE_CODES[0],
);
const [targetLanguage, setTargetLanguage] = useState<string>(
SUPPORTED_LANGUAGE_CODES[1],
);
const [pos, setPos] = useState<string>(SUPPORTED_POS[0]);
const [difficulty, setDifficulty] = useState<string>(DIFFICULTY_LEVELS[0]);
const [rounds, setRounds] = useState<string>(GAME_ROUNDS[0]);
const handleSourceLanguage = (value: string) => {
if (value === targetLanguage) {
setTargetLanguage(sourceLanguage);
}
setSourceLanguage(value);
};
const handleTargetLanguage = (value: string) => {
if (value === sourceLanguage) {
setSourceLanguage(targetLanguage);
}
setTargetLanguage(value);
};
const handleStart = () => {
onStart({
source_language: sourceLanguage,
target_language: targetLanguage,
pos,
difficulty,
rounds,
} as GameRequest);
};
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">lila</h1>
<p className="text-sm text-gray-400">Set up your quiz</p>
</div>
<div className="bg-white rounded-3xl shadow-lg p-6 w-full flex flex-col gap-5">
<SettingGroup
label="I speak"
options={SUPPORTED_LANGUAGE_CODES}
selected={sourceLanguage}
onSelect={handleSourceLanguage}
/>
<SettingGroup
label="I want to learn"
options={SUPPORTED_LANGUAGE_CODES}
selected={targetLanguage}
onSelect={handleTargetLanguage}
/>
<SettingGroup
label="Word type"
options={SUPPORTED_POS}
selected={pos}
onSelect={setPos}
/>
<SettingGroup
label="Difficulty"
options={DIFFICULTY_LEVELS}
selected={difficulty}
onSelect={setDifficulty}
/>
<SettingGroup
label="Rounds"
options={GAME_ROUNDS}
selected={rounds}
onSelect={setRounds}
/>
</div>
<button
onClick={handleStart}
className="w-full py-4 rounded-2xl text-xl font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
>
Start Quiz
</button>
</div>
);
};

View file

@ -1,31 +0,0 @@
type OptionButtonProps = {
text: string;
state: "idle" | "selected" | "disabled" | "correct" | "wrong";
onSelect: () => void;
};
export const OptionButton = ({ text, state, onSelect }: OptionButtonProps) => {
const base =
"w-full py-3 px-6 rounded-2xl text-lg font-semibold transition-all duration-200 border-b-4 cursor-pointer";
const styles = {
idle: "bg-white text-purple-900 border-purple-200 hover:bg-purple-50 hover:border-purple-300 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2",
selected:
"bg-purple-100 text-purple-900 border-purple-400 ring-2 ring-purple-400",
disabled: "bg-gray-100 text-gray-400 border-gray-200 cursor-default",
correct: "bg-emerald-400 text-white border-emerald-600 scale-[1.02]",
wrong: "bg-pink-400 text-white border-pink-600",
};
return (
<button
className={`${base} ${styles[state]}`}
onClick={onSelect}
disabled={
state === "disabled" || state === "correct" || state === "wrong"
}
>
{text}
</button>
);
};

View file

@ -1,96 +0,0 @@
import { useState } from "react";
import type { GameQuestion, AnswerResult } from "@lila/shared";
import { OptionButton } from "./OptionButton";
type QuestionCardProps = {
question: GameQuestion;
questionNumber: number;
totalQuestions: number;
currentResult: AnswerResult | null;
onAnswer: (optionId: number) => void;
onNext: () => void;
};
export const QuestionCard = ({
question,
questionNumber,
totalQuestions,
currentResult,
onAnswer,
onNext,
}: QuestionCardProps) => {
const [selectedOptionId, setSelectedOptionId] = useState<number | null>(null);
const getOptionState = (optionId: number) => {
if (currentResult) {
if (optionId === currentResult.correctOptionId) return "correct" as const;
if (optionId === currentResult.selectedOptionId) return "wrong" as const;
return "disabled" as const;
}
if (optionId === selectedOptionId) return "selected" as const;
return "idle" as const;
};
const handleSelect = (optionId: number) => {
if (currentResult) return;
setSelectedOptionId(optionId);
};
const handleSubmit = () => {
if (selectedOptionId === null) return;
onAnswer(selectedOptionId);
};
const handleNext = () => {
setSelectedOptionId(null);
onNext();
};
return (
<div className="flex flex-col items-center gap-6 w-full max-w-md mx-auto">
<div className="flex items-center gap-2 text-sm font-medium text-purple-400">
<span>
{questionNumber} / {totalQuestions}
</span>
</div>
<div className="bg-white rounded-3xl shadow-lg p-8 w-full text-center">
<h2 className="text-3xl font-bold text-purple-900 mb-2">
{question.prompt}
</h2>
{question.gloss && (
<p className="text-sm text-gray-400 italic">{question.gloss}</p>
)}
</div>
<div className="flex flex-col gap-3 w-full">
{question.options.map((option) => (
<OptionButton
key={option.optionId}
text={option.text}
state={getOptionState(option.optionId)}
onSelect={() => handleSelect(option.optionId)}
/>
))}
</div>
{!currentResult && selectedOptionId !== null && (
<button
onClick={handleSubmit}
className="w-full py-3 rounded-2xl text-lg font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white border-b-4 border-purple-700 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
>
Submit
</button>
)}
{currentResult && (
<button
onClick={handleNext}
className="w-full py-3 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
>
{questionNumber === totalQuestions ? "See Results" : "Next"}
</button>
)}
</div>
);
};

View file

@ -1,60 +0,0 @@
import type { AnswerResult } from "@lila/shared";
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void };
export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
const score = results.filter((r) => r.isCorrect).length;
const total = results.length;
const percentage = Math.round((score / total) * 100);
const getMessage = () => {
if (percentage === 100) return "Perfect! 🎉";
if (percentage >= 80) return "Great job! 🌟";
if (percentage >= 60) return "Not bad! 💪";
if (percentage >= 40) return "Keep practicing! 📚";
return "Don't give up! 🔄";
};
return (
<div className="flex flex-col items-center gap-8 w-full max-w-md mx-auto">
<div className="bg-white rounded-3xl shadow-lg p-10 w-full text-center">
<p className="text-lg font-medium text-purple-400 mb-2">Your Score</p>
<h2 className="text-6xl font-bold text-purple-900 mb-1">
{score}/{total}
</h2>
<p className="text-2xl mb-6">{getMessage()}</p>
<div className="w-full bg-purple-100 rounded-full h-4 mb-2">
<div
className="bg-linear-to-r from-pink-400 to-purple-500 h-4 rounded-full transition-all duration-700"
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-sm text-gray-400">{percentage}% correct</p>
</div>
<div className="flex flex-col gap-2 w-full">
{results.map((result, index) => (
<div
key={result.questionId}
className={`flex items-center gap-3 py-2 px-4 rounded-xl text-sm ${
result.isCorrect
? "bg-emerald-50 text-emerald-700"
: "bg-pink-50 text-pink-700"
}`}
>
<span className="font-bold">{index + 1}.</span>
<span>{result.isCorrect ? "✓ Correct" : "✗ Wrong"}</span>
</div>
))}
</div>
<button
onClick={onPlayAgain}
className="py-3 px-10 rounded-2xl text-lg font-bold bg-purple-600 text-white border-b-4 border-purple-800 hover:bg-purple-500 hover:-translate-y-0.5 active:translate-y-0 active:border-b-2 transition-all duration-200 cursor-pointer"
>
Play Again
</button>
</div>
);
};

View file

@ -1 +0,0 @@
@import "tailwindcss";

View file

@ -1,7 +0,0 @@
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

@ -1,28 +0,0 @@
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import "./index.css";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
// Create a new router instance
const router = createRouter({ routeTree });
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById("root")!;
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
}

View file

@ -1,113 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
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'
const PlayRoute = PlayRouteImport.update({
id: '/play',
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',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
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' | '/login' | '/play'
fileRoutesByTo: FileRoutesByTo
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
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/play': {
id: '/play'
path: '/play'
fullPath: '/play'
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
fullPath: '/about'
preLoaderRoute: typeof AboutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
PlayRoute: PlayRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View file

@ -1,51 +0,0 @@
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 = () => {
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

@ -1,7 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({ component: About });
function About() {
return <div className="p-2">Hello from About!</div>;
}

View file

@ -1,11 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Index });
function Index() {
return (
<div className="p-2 text-3xl text-amber-400">
<h3>Welcome Home!</h3>
</div>
);
}

View file

@ -1,44 +0,0 @@
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,121 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState, useCallback } from "react";
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);
const [results, setResults] = useState<AnswerResult[]>([]);
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true);
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();
setGameSession(data.data);
setCurrentQuestionIndex(0);
setResults([]);
setCurrentResult(null);
setIsLoading(false);
}, []);
const resetToSetup = useCallback(() => {
setGameSession(null);
setIsLoading(false);
setCurrentQuestionIndex(0);
setResults([]);
setCurrentResult(null);
}, []);
const handleAnswer = async (optionId: number) => {
if (!gameSession || currentResult) return;
const question = gameSession.questions[currentQuestionIndex];
if (!question) return;
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,
selectedOptionId: optionId,
}),
});
const data = await response.json();
setCurrentResult(data.data);
};
const handleNext = () => {
if (!currentResult) return;
setResults((prev) => [...prev, currentResult]);
setCurrentQuestionIndex((prev) => prev + 1);
setCurrentResult(null);
};
// Phase: setup
if (!gameSession && !isLoading) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<GameSetup onStart={startGame} />
</div>
);
}
// Phase: loading
if (isLoading || !gameSession) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
<p className="text-purple-400 text-lg font-medium">Loading...</p>
</div>
);
}
// Phase: finished
if (currentQuestionIndex >= gameSession.questions.length) {
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<ScoreScreen results={results} onPlayAgain={resetToSetup} />
</div>
);
}
// Phase: playing
const question = gameSession.questions[currentQuestionIndex]!;
return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<QuestionCard
question={question}
questionNumber={currentQuestionIndex + 1}
totalQuestions={gameSession.questions.length}
currentResult={currentResult}
onAnswer={handleAnswer}
onNext={handleNext}
/>
</div>
);
}
export const Route = createFileRoute("/play")({
component: Play,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
},
});

View file

@ -1,18 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"target": "ES2023",
"types": ["vite/client", "vitest/globals"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src", "vitest.config.ts"]
}

View file

@ -1,8 +0,0 @@
{
"files": [],
"references": [
{ "path": "../../packages/shared" },
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"types": ["node"]
},
"include": ["vite.config.ts"]
}

View file

@ -1,14 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({ target: "react", autoCodeSplitting: true }),
react(),
tailwindcss(),
],
server: { proxy: { "/api": "http://localhost:3000" } },
});

View file

@ -1,3 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { environment: "jsdom", globals: true } });

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,91 +0,0 @@
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,78 +0,0 @@
services:
database:
container_name: lila-database
image: postgres:18.3-alpine3.23
env_file:
- .env
environment:
- PGDATA=/var/lib/postgresql/data
ports:
- "5432:5432"
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
valkey:
container_name: lila-valkey
image: valkey/valkey:9.1-alpine3.23
ports:
- "6379:6379"
restart: unless-stopped
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
api:
container_name: lila-api
build:
context: .
dockerfile: ./apps/api/Dockerfile
target: dev
env_file:
- .env
ports:
- "3000:3000"
volumes:
- ./apps/api:/app/apps/api # Hot reload API code
- ./packages/shared:/app/packages/shared # Hot reload shared
- /app/node_modules
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
valkey:
condition: service_healthy
web:
container_name: lila-web
build:
context: .
dockerfile: ./apps/web/Dockerfile
target: dev
ports:
- "5173:5173"
volumes:
- ./apps/web:/app/apps/web # Hot reload: local edits reflect immediately
- /app/node_modules # Protect container's node_modules from being overwritten
environment:
- VITE_API_URL=http://localhost:3000
restart: unless-stopped
depends_on:
api:
condition: service_healthy
volumes:
lila-db:

View file

@ -1,371 +0,0 @@
# Code Review: `build-top-english-nouns-deck` seed script
Hey, good work getting this to a finished, working state — that's genuinely the hardest part. Below is feedback structured the way a mentor would give it: what the problem is, why it matters in a real codebase, and how to fix it. Work through these one by one when you refactor.
---
## 1. Function names should be imperative, not gerunds
### What you wrote
```ts
const readingFromWordlist = async () => { ... }
const checkingSourceWordsAgainstDB = async () => { ... }
```
### Why it's a problem
Functions represent _actions_. In English, imperative verbs describe actions: `read`, `fetch`, `build`. Gerunds (`reading`, `checking`) describe ongoing processes — they read like you're narrating what's happening rather than declaring what a function does. This isn't just style preference: when you're scanning a call stack or reading `main()`, imperative names parse faster because they match the mental model of "I am calling this to do a thing."
### How to fix it
```ts
const readWordlist = async () => { ... }
const resolveSourceTerms = async () => { ... } // "checking" undersells what it returns
const writeMissingWords = async () => { ... }
```
Note the rename of `checkingSourceWordsAgainstDB``resolveSourceTerms`. The original name describes the _mechanism_ (checking against DB). A better name describes the _result_ (resolving words into term IDs). Callers don't need to know it hits the DB.
### Further reading
- [Clean Code, Chapter 2 Meaningful Names](https://www.oreilly.com/library/view/clean-code-a/9780136083238/) — specifically the section on "Use Intention-Revealing Names"
- [Google TypeScript Style Guide Naming](https://google.github.io/styleguide/tsguide.html#naming-style)
---
## 2. N+1 query pattern in `validateLanguages` and `logLanguageCoverage`
### What you wrote
```ts
for (const language of languages) {
const rows = await db
.selectDistinct({ termId: translations.term_id })
.from(translations)
.where(
and(
inArray(translations.term_id, termIds),
eq(translations.language_code, language),
),
);
}
```
### Why it's a problem
This fires one database query _per language_. If you have 15 supported languages, that's 15 round trips. Each round trip has network latency, connection overhead, and query planning cost. The database already knows how to aggregate across all languages in a single pass — you're just not asking it to.
This pattern is called **N+1** (one query to get the list, then N queries for each item in the list) and it's one of the most common performance mistakes in applications that use databases. At 15 languages it's fine. At 50 languages with 100k terms, your script will be the reason someone gets paged at 2am.
### How to fix it
Ask the database to do the grouping for you in a single query:
```ts
import { count, ne } from "drizzle-orm";
const coverage = await db
.select({
language: translations.language_code,
coveredCount: count(translations.term_id),
})
.from(translations)
.where(
and(
inArray(translations.term_id, termIds),
ne(translations.language_code, sourceLanguage),
),
)
.groupBy(translations.language_code);
const validatedLanguages = coverage
.filter((row) => row.coveredCount === termIds.length)
.map((row) => row.language);
```
One query. The database returns a row per language with the count of covered terms. You filter in JS. Done.
### Further reading
- [Drizzle ORM `groupBy` and aggregations](https://orm.drizzle.team/docs/select#aggregations)
- ["What is the N+1 query problem" — StackOverflow](https://stackoverflow.com/questions/97197/what-is-the-n1-select-query-problem-and-how-can-it-be-avoided)
---
## 3. Two functions doing the same database work
### What you wrote
`validateLanguages` and `logLanguageCoverage` both loop over languages and fire the same query per language. You wrote the same logic twice.
### Why it's a problem
This is a violation of **DRY** (Don't Repeat Yourself). The immediate cost is that any bug in the query exists in two places — fixing one doesn't fix the other. The deeper cost is that it doubles your database load for no reason: you fetch the coverage data, use it to compute `validatedLanguages`, throw it away, then fetch it again just to log it.
### How to fix it
Once you apply the fix from point 2, you have a single `coverage` array. Use it for both purposes:
```ts
const coverage = await db... // single query from point 2
// Use for validation
const validatedLanguages = coverage
.filter((row) => row.coveredCount === termIds.length)
.map((row) => row.language);
// Use for logging
for (const row of coverage) {
console.log(` ${row.language}: ${row.coveredCount} / ${termIds.length} terms covered`);
}
```
No second trip to the database.
### Further reading
- [The DRY Principle](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself)
---
## 4. Unnecessary array copying inside a loop
### What you wrote
```ts
const wordToTermIds = new Map<string, string[]>();
for (const row of rows) {
const existing = wordToTermIds.get(word) ?? [];
wordToTermIds.set(word, [...existing, row.termId]); // spreads the whole array every iteration
}
```
### Why it's a problem
`[...existing, row.termId]` creates a _brand new array_ every time and copies all the previous elements into it. If "bank" has 3 homonyms, you allocate arrays of size 0, 1, 2, and 3 — throwing the first three away. This is an `O(n²)` memory allocation pattern. For 1000 words it's invisible. In a tighter loop or with more data, it adds up.
This pattern comes from functional programming habits (immutability is good there). But in a one-off script building a local data structure, there's no reason to avoid mutation.
### How to fix it
```ts
const wordToTermIds = new Map<string, string[]>();
for (const row of rows) {
const word = row.text.toLowerCase();
if (!wordToTermIds.has(word)) {
wordToTermIds.set(word, []);
}
wordToTermIds.get(word)!.push(row.termId);
}
```
Get the array once, push into it. No copies.
### Further reading
- [MDN Array.prototype.push()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/push)
- [Big O Notation primer](https://www.freecodecamp.org/news/big-o-notation-why-it-matters-and-why-it-doesnt-1674cfa8a23c/) — worth understanding O(n²) vs O(n)
---
## 5. No database transaction — your "idempotent" script can corrupt state
### What you wrote
```ts
deckId = await createDeck(validatedLanguages); // step 1
const addedCount = await addTermsToDeck(deckId, termIds); // step 2
await updateValidatedLanguages(deckId, validatedLanguages); // step 3
```
### Why it's a problem
These three operations are separate database round trips with nothing tying them together. If step 2 throws (network blip, constraint violation, anything), you end up with a deck row that has no terms. Run the script again and it finds the existing deck, skips creation, then tries to add terms — but now your `validated_languages` from the previous partial run might be stale. The script _appears_ to recover, but you can't be sure of what state you're in.
A **transaction** is a guarantee: either all steps succeed together, or none of them do. If anything fails mid-way, the database rolls back to the state before the transaction started. This is fundamental to writing scripts that touch multiple tables.
### How to fix it
```ts
await db.transaction(async (tx) => {
const existingDeck = await findExistingDeck(tx);
let deckId: string;
if (!existingDeck) {
deckId = await createDeck(tx, validatedLanguages);
} else {
deckId = existingDeck.id;
}
await addTermsToDeck(tx, deckId, termIds);
await updateValidatedLanguages(tx, deckId, validatedLanguages);
});
```
You'll need to thread the `tx` (transaction context) through your functions instead of using the global `db` — that's the key change.
### Further reading
- [Drizzle ORM Transactions](https://orm.drizzle.team/docs/transactions)
- [PostgreSQL What is a Transaction?](https://www.postgresql.org/docs/current/tutorial-transactions.html)
- [ACID properties explained](https://www.databricks.com/glossary/acid-transactions) — Atomicity is what protects you here
---
## 6. The `isNewDeck` flag is unnecessary
### What you wrote
```ts
let isNewDeck: boolean;
if (!existingDeck) {
deckId = await createDeck(validatedLanguages);
isNewDeck = true;
} else {
deckId = existingDeck.id;
isNewDeck = false;
}
// ...later...
if (!isNewDeck) {
await updateValidatedLanguages(deckId, validatedLanguages);
}
```
### Why it's a problem
You introduced `isNewDeck` to avoid calling `updateValidatedLanguages` when the deck was just created — reasoning that you already passed `validatedLanguages` to `createDeck`. But that means you're calling `updateValidatedLanguages` in _one path_ and `createDeck(..., validatedLanguages)` in the _other_ path. The intent (always keep validated languages current) is the same in both cases, but the code splits it into two branches you have to mentally reconcile.
The cleaner model: always call `updateValidatedLanguages` after finding or creating the deck. Then `createDeck` doesn't need `validatedLanguages` at all, and `isNewDeck` disappears.
### How to fix it
```ts
const deckId = existingDeck ? existingDeck.id : await createDeck(); // no validatedLanguages needed here
await addTermsToDeck(deckId, termIds);
await updateValidatedLanguages(deckId, validatedLanguages); // always runs
```
Fewer variables, one clear flow.
---
## 7. Comments explain _what_, not _why_
### What you wrote
```ts
// new Set() automatically discards duplicate values,
// and spreading it back with ... converts it to a plain array again.
// So if "bank" appears twice in the file,
// the resulting array will only contain it once.
const words = [
...new Set(
raw
.split("\n")
.map((w) => w.trim().toLowerCase())
.filter(Boolean),
),
];
```
### Why it's a problem
Comments that re-explain what the code literally does are called **noise comments**. They add length without adding understanding — any developer who can read this script already knows what `Set` does. Worse, they can get out of date if the code changes but the comment doesn't.
Good comments explain _why_ a decision was made, not _what_ the code does. The code already says what it does.
Meanwhile, your most complex line — `const termIds = [...new Set(Array.from(wordToTermIds.values()).flat())]` — has no comment at all. That's the one that earns a note.
### How to fix it
```ts
// Deduplicate: multiple words can map to the same term ID (e.g. via synonyms)
const termIds = [...new Set(Array.from(wordToTermIds.values()).flat())];
```
And remove the Set explanation from `readWordlist`. The code is clear.
### Further reading
- [Clean Code, Chapter 4 Comments](https://www.oreilly.com/library/view/clean-code-a/9780136083238/) — specifically "Explain Yourself in Code" and "Noise Comments"
---
## 8. The finished roadmap comment should be deleted
### What you wrote
```ts
/*
* roadmap
* [x] Setup
* [x] Read wordlist
* ...all checked off
*/
```
### Why it's a problem
This was useful _while you were planning_. Now that every item is checked, it communicates nothing except "this is done" — which the existence of a working script already communicates. Leaving it in adds noise to the file header and signals that you're not sure what belongs in source control vs. a task tracker.
### How to fix it
Delete it. Use GitHub Issues, a Notion doc, or even a scratchpad file for planning notes. Source code is the output of planning, not the place to store it.
---
## 9. No log levels — everything goes to `console.log`
### What you wrote
```ts
console.log("📖 Reading word list...");
console.log(` ${sourceWords.length} words loaded\n`);
// ...and so on for every step
```
### Why it's a problem
In a real environment — CI/CD pipelines, server logs, anything beyond your local terminal — all of this output lands in the same stream at the same priority. Actual errors (`console.error`) get buried in progress logs. There's no way to run the script quietly when you just need the summary, or verbosely when you're debugging.
For a one-off seed script this is low priority, but it's a habit worth building early.
### How to fix it
At minimum, use `console.error` for actual errors (not just in the catch block — also for things like "deck creation returned no ID"). For the detailed per-language breakdown, consider putting it behind a `--verbose` CLI flag so you can run the script cleanly in CI without dumping hundreds of lines of coverage data.
```ts
// Basic approach
if (process.argv.includes("--verbose")) {
await logLanguageCoverage(termIds);
}
```
### Further reading
- [Node.js `process.argv`](https://nodejs.org/en/learn/command-line/nodejs-accept-arguments-from-the-command-line)
- For a proper solution later: [pino](https://github.com/pinojs/pino) — a lightweight structured logger widely used in Node.js
---
## Summary
| # | Issue | Priority |
| --- | ------------------------------ | --------------------------------------- |
| 1 | Gerund function names | Low — style, but builds good habits |
| 2 | N+1 queries | High — real performance impact |
| 3 | Duplicate query logic | High — bugs in two places |
| 4 | Array spread in loop | Medium — inefficient pattern to unlearn |
| 5 | No transaction | High — can corrupt database state |
| 6 | `isNewDeck` flag | Low — unnecessary complexity |
| 7 | Comments explain what, not why | Low — style, but important long-term |
| 8 | Roadmap comment left in | Low — cleanup |
| 9 | No log levels | Low — good habit to build |
Start with **2, 3, and 5** — those are the ones that would cause real problems in production. The rest are about writing code that's easier to read and maintain over time.
Good luck with the refactor. Come back with the updated script when you're done.

View file

@ -1,361 +0,0 @@
# Decisions Log
A record of non-obvious technical decisions made during development, with reasoning. Intended to preserve context across sessions. Grouped by topic area.
---
## Tooling
### Monorepo: pnpm workspaces (not Turborepo)
Turborepo adds parallel task running and build caching on top of pnpm workspaces. For a two-app monorepo of this size, plain pnpm workspace commands are sufficient and there is one less tool to configure and maintain.
### TypeScript runner: `tsx` (not `ts-node`)
`tsx` is faster, requires no configuration, and uses esbuild under the hood. `ts-node` is older and more complex to configure. `tsx` does not do type checking — that is handled separately by `tsc` and the editor. Installed as a dev dependency in `apps/api` only.
### ORM: Drizzle (not Prisma)
Drizzle is lighter — no binary, no engine. Queries map closely to SQL. Migrations are plain SQL files. Works naturally with Zod for type inference. Prisma would add Docker complexity (engine binary in containers) and abstraction that is not needed for this schema.
### WebSocket: `ws` library (not Socket.io)
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: Better Auth (not OpenAuth or Keycloak)
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.
---
## Docker
### 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 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. HMR requires Vite's WebSocket dev server. Production will use Nginx to serve static Vite build output.
---
## Architecture
### 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 (used by supertest).
### 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 `GameQuestion`. 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 via the error handler.
### POST not GET for game start
`GET` requests have no body. Game configuration is submitted as a JSON body → `POST` is semantically correct.
### 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. Keeps the experience symmetric.
### Room model: room codes (not matchmaking queue)
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.
---
## TypeScript Configuration
### Base config: no `lib`, `module`, or `moduleResolution`
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"`.
### `apps/web` tsconfig: deferred to Vite scaffold
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` (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`.
---
## ESLint
### Two-config approach for `apps/web`
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. Produces a single aggregated report.
---
## Data Model
### Users: Better Auth manages the user table
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`. `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.
### 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`)
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)
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)
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 pairs are implicitly defined by `decks.source_language` + `decks.validated_languages`. The table was redundant.
### Terms: `synset_id` nullable (not NOT NULL)
Non-WordNet terms won't have a synset ID. Postgres `UNIQUE` on a nullable column allows multiple NULL values.
### Terms: `source` + `source_id` columns
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.
### `cefr_level` on `translations` (not `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. Added as nullable `varchar(2)` with CHECK.
### Categories + term_categories: empty for MVP
Schema exists. Grammar maps to POS (already on `terms`), Media maps to deck membership. Thematic categories require a metadata source still under research.
### CHECK over pgEnum for extensible value sets
`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.
### `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.
### Unique constraints make explicit FK indexes redundant
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.
---
## Data Pipeline
### Seeding v1: batch, truncate-based
For dev/first-time setup. Read JSON, batch inserts in groups of 500, truncate tables before each run. Simple and fast.
Key pitfalls encountered:
- 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
### 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`.
---
## Open Research
### Semantic category metadata source
Categories (`animals`, `kitchen`, etc.) are in the schema but empty. Options researched:
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
Raw frequency ranks need mapping to A1C2 bands before tiered decks are meaningful. Decision pending.
### Future extensions: morphology and pronunciation
All deferred post-MVP, purely additive (new tables referencing existing `terms`):
- `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,233 +0,0 @@
# Deployment Guide — lilastudy.com
This document describes the production deployment of the lila vocabulary trainer on a Hetzner VPS.
## Infrastructure Overview
- **VPS**: Hetzner, Debian 13, ARM64 (aarch64), 4GB RAM
- **Domain**: lilastudy.com (DNS managed on Hetzner, wildcard `*.lilastudy.com` configured)
- **Reverse proxy**: Caddy (Docker container, automatic HTTPS via Let's Encrypt)
- **Container registry**: Forgejo built-in package registry
- **Git server**: Forgejo
### Subdomain Routing
| Subdomain | Service | Container port |
|---|---|---|
| `lilastudy.com` | Frontend (nginx serving static files) | 80 |
| `api.lilastudy.com` | Express API | 3000 |
| `git.lilastudy.com` | Forgejo (web UI + container registry) | 3000 |
### Ports Exposed to the Internet
| Port | Service |
|---|---|
| 80 | Caddy (HTTP, redirects to HTTPS) |
| 443 | Caddy (HTTPS) |
| 2222 | Forgejo SSH (git clone/push) |
All other services (Postgres, API, frontend) communicate only over the internal Docker network.
## VPS Base Setup
The server has SSH key auth, ufw firewall (ports 22, 80, 443, 2222), and fail2ban configured. Docker and Docker Compose are installed via Docker's official apt repository.
Locale `en_GB.UTF-8` was generated alongside `en_US.UTF-8` to suppress SSH locale warnings from the dev laptop.
## Directory Structure on VPS
```
~/lila-app/
├── docker-compose.yml
├── Caddyfile
└── .env
~/lila-db-backups/
├── lila-db-YYYY-MM-DD_HHMM.sql.gz
└── backup.sh
```
## Docker Compose Stack
All services run in a single `docker-compose.yml` on a shared `lila-network`. The app images are pulled from the Forgejo registry.
### Services
- **caddy** — reverse proxy, only container with published ports (80, 443)
- **api** — Express backend, image from `git.lilastudy.com/forgejo-lila/lila-api:latest`
- **web** — nginx serving Vite-built static files, image from `git.lilastudy.com/forgejo-lila/lila-web:latest`
- **database** — PostgreSQL with a named volume (`lila-db`) for persistence
- **forgejo** — git server + container registry, SSH on port 2222, data in named volume (`forgejo-data`)
### Key Design Decisions
- No ports exposed on internal services — only Caddy faces the internet
- Frontend is built to static files at Docker build time; no Node process in production
- `VITE_API_URL` is baked in during the Docker build via a build arg
- The API reads all environment-specific config from `.env` (CORS origin, auth URLs, DB connection, cookie domain)
## Environment Variables
Production `.env` on the VPS:
```
DATABASE_URL=postgres://postgres:PASSWORD@database:5432/lila
POSTGRES_USER=postgres
POSTGRES_PASSWORD=PASSWORD
POSTGRES_DB=lila
BETTER_AUTH_SECRET=GENERATED_SECRET
BETTER_AUTH_URL=https://api.lilastudy.com
CORS_ORIGIN=https://lilastudy.com
COOKIE_DOMAIN=.lilastudy.com
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
```
Note: `DATABASE_URL` host is `database` (the Docker service name). Password in `DATABASE_URL` must match `POSTGRES_PASSWORD`.
## Docker Images — Build and Deploy
Images are built on the dev laptop with cross-compilation for ARM64, pushed to the Forgejo registry, and pulled on the VPS.
### Build (on dev laptop)
```bash
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker build --platform linux/arm64 \
-t git.lilastudy.com/forgejo-lila/lila-api:latest \
--target runner -f apps/api/Dockerfile .
docker build --platform linux/arm64 \
-t git.lilastudy.com/forgejo-lila/lila-web:latest \
--target production \
--build-arg VITE_API_URL=https://api.lilastudy.com \
-f apps/web/Dockerfile .
```
QEMU registration may need to be re-run after Docker or system restarts.
### Push (from dev laptop)
```bash
docker login git.lilastudy.com
docker push git.lilastudy.com/forgejo-lila/lila-api:latest
docker push git.lilastudy.com/forgejo-lila/lila-web:latest
```
### Deploy (on VPS)
```bash
docker compose pull
docker compose up -d
```
To deploy a single service without restarting the whole stack:
```bash
docker compose pull api
docker compose up -d api
```
### Cleanup
Remove unused images after deployments:
```bash
docker image prune -f # safe — only removes dangling images
docker system prune -a # aggressive — removes all unused images
```
## Dockerfiles
### API (`apps/api/Dockerfile`)
Multi-stage build: base → deps → dev → builder → runner. The `runner` stage does a fresh `pnpm install --prod` to get correct symlinks. Output is at `apps/api/dist/src/server.js` due to monorepo rootDir configuration.
### Frontend (`apps/web/Dockerfile`)
Multi-stage build: base → deps → dev → builder → production. The `builder` stage compiles with `VITE_API_URL` baked in. The `production` stage is `nginx:alpine` serving static files from `dist/`. Includes a custom `nginx.conf` for SPA fallback routing (`try_files $uri $uri/ /index.html`).
## Monorepo Package Exports
Both `packages/shared` and `packages/db` have their `exports` in `package.json` pointing to compiled JavaScript (`./dist/src/...`), not TypeScript source. This is required for production builds where Node cannot run `.ts` files. In dev, packages must be built before running the API.
## Database
### Initial Seeding
The production database was initially populated via `pg_dump` from the dev laptop:
```bash
# On dev laptop
docker exec lila-database pg_dump -U USER DB > seed.sql
scp seed.sql lila@VPS_IP:~/lila-app/
# On VPS
docker exec -i lila-database psql -U postgres -d lila < seed.sql
```
### Ongoing Data Updates
The seeding script (`packages/db/src/seeding-datafiles.ts`) uses `onConflictDoNothing()` on all inserts, making it idempotent. New vocabulary data (e.g. Spanish words) can be added by running the seeding script against production — it inserts only new records without affecting existing data or user tables.
### Schema Migrations
Schema changes are managed by Drizzle. Deploy order matters:
1. Run migration first (database gets new structure)
2. Deploy new API image (code uses new structure)
Reversing this order causes the API to crash on missing columns/tables.
## Backups
A cron job runs daily at 3:00 AM, dumping the database to a compressed SQL file and keeping the last 7 days:
```bash
# ~/backup.sh
0 3 * * * /home/lila/backup.sh
```
Backups are stored in `~/backups/` as `lila-db-YYYY-MM-DD_HHMM.sql.gz`.
### Pulling Backups to Dev Laptop
A script on the dev laptop syncs new backups on login:
```bash
# ~/pull-backups.sh (runs via .profile on login)
rsync -avz --ignore-existing --include="*.sql.gz" --exclude="*" lila@VPS_IP:~/backups/ ~/lila-backups/
```
### Restoring from Backup
```bash
gunzip -c lila-db-YYYY-MM-DD_HHMM.sql.gz | docker exec -i lila-database psql -U postgres -d lila
```
## OAuth Configuration
Google and GitHub OAuth apps must have both dev and production redirect URIs:
- **Google Cloud Console**: Authorized redirect URIs include both `http://localhost:3000/api/auth/callback/google` and `https://api.lilastudy.com/api/auth/callback/google`
- **GitHub Developer Settings**: Authorization callback URL includes both localhost and production
## Forgejo SSH
The dev laptop's `~/.ssh/config` maps `git.lilastudy.com` to port 2222:
```
Host git.lilastudy.com
Port 2222
```
This allows standard git commands without specifying the port.
## Known Issues and Future Work
- **CI/CD**: Currently manual build-push-pull cycle. Plan: Forgejo Actions with a runner on the VPS building ARM images natively (eliminates QEMU cross-compilation)
- **Backups**: Offsite backup storage (Hetzner Object Storage or similar) should be added
- **Valkey**: Not in the production stack yet. Will be added when multiplayer requires session/room state
- **Monitoring/logging**: No centralized logging or uptime monitoring configured

View file

@ -1,130 +0,0 @@
# notes
## tasks
- pinning dependencies in package.json files
- rethink organisation of datafiles and wordlists
## problems+thoughts
### IMPORTANT
verify if hetzner domain needs to be pushed, theres a change on hetzner and some domains need to be migrated
### 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)
- keep the vps clean (e.g. old docker images/containers)
### 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)
- openapi
- bruno for api testing
- tailscale
- husky/lint-staged
- musicforprogramming.net
## openwordnet
download libraries via
```bash
python -c 'import wn; wn.download("omw-fr")';
```
libraries:
odenet:1.4
omw-es:1.4
omw-fr:1.4
omw-it:1.4
omw-en:1.4
upgrade wn package:
```bash
pip install --upgrade wn
```
check if wn is available, eg italian:
```bash
python -c "import wn; print(len(wn.words(lang='it', lexicon='omw-it:1.4')))"
```
remove a library:
```bash
python -c "import wn; wn.remove('oewn:2024')"﬌ python -c "import wn; wn.remove('oewn:2024')"
```
list all libraries:
```bash
python -c "import wn; print(wn.lexicons())"
```

View file

@ -1,191 +1,149 @@
# lila — Roadmap
# Vocabulary Trainer — Roadmap
Each phase produces a working increment. Nothing is built speculatively.
Each phase produces a working, deployable increment. Nothing is built speculatively.
---
## Phase 0 — Foundation ✅
## 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.
**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.
- [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`
- [ ] Initialise pnpm workspace monorepo: `apps/web`, `apps/api`, `packages/shared`, `packages/db`
- [ ] Configure TypeScript project references across packages
- [ ] Set up ESLint + Prettier with shared configs in root
- [ ] Set up Vitest in `api` and `web`
- [ ] Scaffold Express app with `GET /api/health`
- [ ] Scaffold Vite + React app with TanStack Router (single root route)
- [ ] Configure Drizzle ORM + connection to local PostgreSQL
- [ ] Write first migration (empty — just validates the pipeline works)
- [ ] `docker-compose.yml` for local dev: `api`, `web`, `postgres`, `valkey`
- [ ] `.env.example` files for `apps/api` and `apps/web`
---
## Phase 1 — Vocabulary Data + API ✅
## Phase 1 — Vocabulary Data
**Goal**: Word data lives in the DB and can be queried via the API.
**Done when**: `GET /api/terms?pair=en-it&limit=10` returns 10 terms, each with 3 distractors attached.
**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.
### Data pipeline
- [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
### Schemas
- [x] Define `GameRequestSchema` in `packages/shared`
- [x] Define `AnswerOption`, `GameQuestion`, `GameSession`, `AnswerSubmission`, `AnswerResult` schemas
- [x] Derived types exported from constants (`SupportedLanguageCode`, `SupportedPos`, `DifficultyLevel`)
### Model layer
- [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`
### Service layer
- [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)
- [ ] Run `scripts/extract_omw.py` locally → generates `packages/db/src/seed.json`
- [ ] Write Drizzle schema: `terms`, `translations`, `language_pairs`
- [ ] Write and run migration
- [ ] Write `packages/db/src/seed.ts` (reads `seed.json`, populates tables)
- [ ] Implement `TermRepository.getRandom(pairId, limit)`
- [ ] Implement `QuizService.attachDistractors(terms)` — same POS, server-side, no duplicates
- [ ] Implement `GET /language-pairs` and `GET /terms` endpoints
- [ ] Define Zod response schemas in `packages/shared`
- [ ] Unit tests for `QuizService` (correct POS filtering, never includes the answer)
---
## Phase 2 — Singleplayer Quiz UI ✅
## Phase 2 — Auth
**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.
**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)
- [ ] Add OpenAuth service to `docker-compose.yml`
- [ ] Write Drizzle schema: `users`
- [ ] Write and run migration
- [ ] Implement JWT validation middleware in `apps/api`
- [ ] Implement `GET /api/auth/me` (validate token, upsert user row, 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
---
## Phase 3 — Auth
## Phase 3 — Single-player Mode
**Goal**: A logged-in user can complete a full solo quiz session.
**Done when**: User sees 10 questions, picks answers, sees their final score.
**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
- [ ] 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`
---
## 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.
## Phase 4 — Multiplayer Rooms (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)
- [ ] 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 + join-by-code
- [ ] Frontend: `/multiplayer/room/:code` — player list, room code, "Start Game" (host only)
- [ ] Frontend: WS client singleton with reconnect
- [ ] 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
---
## 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, enforce 15s server timer
- [ ] `room:start` WS handler → broadcast first `game:question`
- [ ] `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 DB (transactional)
- [ ] After N rounds → broadcast `game:finished`, update `rooms.status` + `room_players.score` in DB
- [ ] 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)
- [ ] 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)
---
## 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`
- [ ] Production `.env` files on VPS
- [ ] 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
- [ ] Seed production DB
- [ ] Smoke test: login → solo game → multiplayer game end-to-end
- [ ] Seed production DB (run `seed.ts` once)
- [ ] Smoke test: login → solo game → create room → multiplayer game end-to-end
---
## Phase 7 — Polish & Hardening
## Phase 7 — Polish & Hardening *(post-MVP)*
**Goal:** Production-ready for real users.
Not required to ship, but address before real users arrive.
- [ ] Rate limiting on API endpoints
- [ ] Rate limiting on API endpoints (`express-rate-limit`)
- [ ] 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)
- [ ] CI/CD pipeline (GitHub Actions → SSH deploy on push to `main`)
- [ ] Database backups (cron → Hetzner Object Storage)
---
## Dependency Graph
```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)
```
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)
```

View file

@ -1,335 +1,436 @@
# lila — Project Specification
# Vocabulary Trainer — Project Specification
> **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.
## 1. Overview
---
## 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.
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.
### 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. Full Product Vision (Long-Term)
## 2. Technology Stack
- 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
| 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 16 |
| ORM | Drizzle ORM |
| Cache / Queue | Valkey 8 |
| Auth | OpenAuth (Google + GitHub) |
| Validation | Zod (shared schemas) |
| Testing | Vitest, React Testing Library |
| Linting / Formatting | ESLint, Prettier |
| Containerisation | Docker, Docker Compose |
| Hosting | Hetzner VPS |
This is the full vision. The MVP deliberately ignores most of it.
### 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.
---
## 3. MVP Scope
## 3. Repository Structure
**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/
│ ├── 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
│ ├── 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
├── packages/
│ ├── 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
│ ├── shared/ # Zod schemas, TypeScript types, constants
│ └── db/ # Drizzle schema, migrations, seed script
├── scripts/
│ └── extract_omw.py # One-time WordNet + OMW extraction → seed.json
├── docker-compose.yml
└── pnpm-workspace.yaml
├── docker-compose.prod.yml
├── pnpm-workspace.yaml
└── package.json
```
`packages/shared` is the contract between frontend and backend. All request/response shapes are defined there as Zod schemas — never duplicated.
`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
## 6. Architecture
### The Layered Architecture
```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
`pnpm-workspace.yaml` declares:
```
packages:
- 'apps/*'
- 'packages/*'
```
**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.
### Root scripts
### Monorepo Package Responsibilities
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
| 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`.
For parallel dev, use `concurrently` or just two terminal tabs for MVP.
---
## 7. Data Model (Current State)
## 4. Architecture — N-Tier / Layered
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`.
**Core tables:** `terms`, `translations`, `term_glosses`, `decks`, `deck_terms`, `categories`, `term_categories`
Key columns on `terms`: `id` (uuid), `pos` (CHECK-constrained), `source`, `source_id` (unique pair for idempotent imports)
Key columns on `translations`: `id`, `term_id` (FK), `language_code` (CHECK-constrained), `text`, `cefr_level` (nullable varchar(2), CHECK A1C2)
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.
Full schema is in `packages/db/src/db/schema.ts`.
---
## 8. API
### Endpoints
```text
POST /api/v1/game/start GameRequest → GameSession
POST /api/v1/game/answer AnswerSubmission → AnswerResult
GET /api/v1/health Health check
```
┌────────────────────────────────────┐
│ 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
└────────────────────────────────────┘
```
### Schemas (packages/shared)
**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)
Each layer only communicates with the layer directly below it. Business logic lives in services, not in route handlers or repositories.
---
## 9. Game Mechanics
## 5. Infrastructure
- **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)
### Domain structure
| Subdomain | Service |
|---|---|
| `app.yourdomain.com` | React frontend |
| `api.yourdomain.com` | Express API + WebSocket |
| `auth.yourdomain.com` | OpenAuth service |
### Docker Compose services (production)
| 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 |
```
nginx-proxy (:80/:443)
app.domain → web:80
api.domain → api:3000 (HTTP + WS upgrade)
auth.domain → openauth:3001
```
SSL is fully automatic via `nginx-proxy` + `acme-companion`. No manual Certbot needed.
---
## 10. Working Methodology
## 6. Data Model
This project is a learning exercise. The goal is to understand the code, not just to ship it.
### Design principle
Words are modelled as language-neutral **terms** with one or more **translations** per language. Adding a new language pair (e.g. EnglishFrench) requires **no schema changes** — only new rows in `translations` and `language_pairs`. The flat `english/italian` column pattern is explicitly avoided.
### How to use an LLM for help
### Core tables
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
```
terms
id uuid PK
synset_id text UNIQUE -- WordNet synset offset e.g. "wn:01234567n"
pos varchar(20) -- "noun" | "verb" | "adjective"
frequency_rank integer -- 11000, reserved for difficulty filtering
created_at timestamptz
### Refactoring workflow
translations
id uuid PK
term_id uuid FK → terms.id
language_code varchar(10) -- BCP 47: "en", "it", "de", ...
text text
UNIQUE (term_id, language_code)
After completing a task: share the code, ask what to refactor and why. The LLM should explain the concept, not write the implementation.
language_pairs
id uuid PK
source varchar(10) -- "en"
target varchar(10) -- "it"
label text -- "English → Italian"
active boolean DEFAULT true
UNIQUE (source, target)
---
users
id uuid PK -- OpenAuth sub claim
email varchar(255) UNIQUE
display_name varchar(100)
games_played integer DEFAULT 0
games_won integer DEFAULT 0
created_at timestamptz
last_login_at timestamptz
## 11. Post-MVP Ladder
rooms
id uuid PK
code varchar(8) UNIQUE -- human-readable e.g. "WOLF-42"
host_id uuid FK → users.id
pair_id uuid FK → language_pairs.id
status text -- "waiting" | "in_progress" | "finished"
max_players smallint DEFAULT 4
round_count smallint DEFAULT 10
created_at timestamptz
| 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 |
room_players
room_id uuid FK → rooms.id
user_id uuid FK → users.id
score integer DEFAULT 0
joined_at timestamptz
PRIMARY KEY (room_id, user_id)
```
### Future Data Model Extensions (deferred, additive)
### Indexes
- `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)
```sql
CREATE INDEX ON terms (pos, frequency_rank);
CREATE INDEX ON rooms (status);
CREATE INDEX ON room_players (user_id);
```
---
## 14. Game Flow (Future)
## 7. Vocabulary Data — WordNet + OMW
Singleplayer: choose direction (en→it or it→en) → top-level category → part of speech → difficulty (A1C2) → round count → game starts.
### Source
- **Princeton WordNet** — English words + synset IDs
- **Open Multilingual Wordnet (OMW)** — Italian translations keyed by synset ID
**Top-level categories (post-MVP):**
### Extraction process
1. Run `scripts/extract_omw.py` once locally using NLTK
2. Filter to the 1 000 most common nouns (by WordNet frequency data)
3. Output: `packages/db/src/seed.json` — committed to the repo
4. `packages/db/src/seed.ts` reads the JSON and populates `terms` + `translations`
- **Grammar** — practice nouns, verb conjugations, etc.
- **Media** — practice vocabulary from specific books, films, songs, etc.
- **Thematic** — animals, kitchen, etc. (requires category metadata research)
`terms.synset_id` stores the WordNet offset (e.g. `wn:01234567n`) 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
```
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
```
### Room state in Valkey
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.
---
## 11. 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.
---
## 12. Frontend Structure
```
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
```
### 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
| 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 | — |
Tests are co-located with source files (`*.test.ts` / `*.test.tsx`).
**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
### Documentation
- [ ] `SPEC.md` complete
- [ ] `.env.example` files for all apps
- [ ] `README.md` with local dev setup instructions
---
## 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

View file

@ -1,43 +0,0 @@
import eslint from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import pluginRouter from "@tanstack/eslint-plugin-router";
export default defineConfig([
globalIgnores([
"**/dist/**",
"node_modules/",
"eslint.config.mjs",
"**/*.config.ts",
"routeTree.gen.ts",
]),
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
eslintConfigPrettier,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
files: ["apps/web/**/*.{ts,tsx}"],
extends: [
...pluginRouter.configs["flat/recommended"],
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
},
{
files: ["apps/web/src/routes/**/*.{ts,tsx}"],
rules: { "react-refresh/only-export-components": "off" },
},
]);

View file

@ -1,3 +0,0 @@
[tools]
node = "24.14.0"
python = "latest"

View file

@ -1,30 +0,0 @@
{
"name": "lila",
"version": "1.0.0",
"description": "a vocabulary trainer",
"private": true,
"scripts": {
"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 .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"packageManager": "pnpm@10.33.0",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tanstack/eslint-plugin-router": "^1.161.6",
"@vitest/coverage-v8": "^4.1.0",
"concurrently": "^9.2.1",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0"
}
}

View file

@ -1,15 +0,0 @@
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";
import { resolve, dirname } from "path";
import { fileURLToPath } from "url";
config({
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../.env"),
});
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "postgresql",
dbCredentials: { url: process.env["DATABASE_URL"]! },
});

View file

@ -1,82 +0,0 @@
CREATE TABLE "deck_terms" (
"deck_id" uuid NOT NULL,
"term_id" uuid NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "deck_terms_deck_id_term_id_pk" PRIMARY KEY("deck_id","term_id")
);
--> statement-breakpoint
CREATE TABLE "decks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"source_language" varchar(10) NOT NULL,
"validated_languages" varchar(10)[] DEFAULT '{}' NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "unique_deck_name" UNIQUE("name","source_language"),
CONSTRAINT "source_language_check" CHECK ("decks"."source_language" IN ('en', 'it')),
CONSTRAINT "validated_languages_check" CHECK (validated_languages <@ ARRAY['en', 'it']::varchar[]),
CONSTRAINT "validated_languages_excludes_source" CHECK (NOT ("decks"."source_language" = ANY("decks"."validated_languages")))
);
--> statement-breakpoint
CREATE TABLE "language_pairs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_language" varchar(10) NOT NULL,
"target_language" varchar(10) NOT NULL,
"label" text,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "unique_source_target" UNIQUE("source_language","target_language"),
CONSTRAINT "source_language_check" CHECK ("language_pairs"."source_language" IN ('en', 'it')),
CONSTRAINT "target_language_check" CHECK ("language_pairs"."target_language" IN ('en', 'it')),
CONSTRAINT "no_self_pair" CHECK ("language_pairs"."source_language" != "language_pairs"."target_language")
);
--> statement-breakpoint
CREATE TABLE "term_glosses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"term_id" uuid NOT NULL,
"language_code" varchar(10) NOT NULL,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "unique_term_gloss" UNIQUE("term_id","language_code","text")
);
--> statement-breakpoint
CREATE TABLE "terms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"synset_id" text NOT NULL,
"pos" varchar(20) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "terms_synset_id_unique" UNIQUE("synset_id"),
CONSTRAINT "pos_check" CHECK ("terms"."pos" IN ('noun'))
);
--> statement-breakpoint
CREATE TABLE "translations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"term_id" uuid NOT NULL,
"language_code" varchar(10) NOT NULL,
"text" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "unique_translations" UNIQUE("term_id","language_code","text")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"openauth_sub" text NOT NULL,
"email" varchar(255),
"display_name" varchar(100),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_login_at" timestamp with time zone,
CONSTRAINT "users_openauth_sub_unique" UNIQUE("openauth_sub"),
CONSTRAINT "users_email_unique" UNIQUE("email"),
CONSTRAINT "users_display_name_unique" UNIQUE("display_name")
);
--> statement-breakpoint
ALTER TABLE "deck_terms" ADD CONSTRAINT "deck_terms_deck_id_decks_id_fk" FOREIGN KEY ("deck_id") REFERENCES "public"."decks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deck_terms" ADD CONSTRAINT "deck_terms_term_id_terms_id_fk" FOREIGN KEY ("term_id") REFERENCES "public"."terms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "term_glosses" ADD CONSTRAINT "term_glosses_term_id_terms_id_fk" FOREIGN KEY ("term_id") REFERENCES "public"."terms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "translations" ADD CONSTRAINT "translations_term_id_terms_id_fk" FOREIGN KEY ("term_id") REFERENCES "public"."terms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_deck_terms_term" ON "deck_terms" USING btree ("term_id");--> statement-breakpoint
CREATE INDEX "idx_pairs_active" ON "language_pairs" USING btree ("active","source_language","target_language");--> statement-breakpoint
CREATE INDEX "idx_term_glosses_term" ON "term_glosses" USING btree ("term_id");--> statement-breakpoint
CREATE INDEX "idx_terms_pos" ON "terms" USING btree ("pos");--> statement-breakpoint
CREATE INDEX "idx_translations_lang" ON "translations" USING btree ("language_code","term_id");

View file

@ -1 +0,0 @@
DROP INDEX "idx_deck_terms_term";

View file

@ -1,40 +0,0 @@
CREATE TABLE "term_topics" (
"term_id" uuid NOT NULL,
"topic_id" uuid NOT NULL,
CONSTRAINT "term_topics_term_id_topic_id_pk" PRIMARY KEY("term_id","topic_id")
);
--> statement-breakpoint
CREATE TABLE "topics" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" varchar(50) NOT NULL,
"label" text NOT NULL,
"description" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "topics_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "language_pairs" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint
DROP TABLE "language_pairs" CASCADE;--> statement-breakpoint
ALTER TABLE "terms" DROP CONSTRAINT "terms_synset_id_unique";--> statement-breakpoint
ALTER TABLE "terms" DROP CONSTRAINT "pos_check";--> statement-breakpoint
DROP INDEX "idx_term_glosses_term";--> statement-breakpoint
DROP INDEX "idx_terms_pos";--> statement-breakpoint
DROP INDEX "idx_translations_lang";--> statement-breakpoint
ALTER TABLE "decks" ADD COLUMN "type" varchar(20) NOT NULL;--> statement-breakpoint
ALTER TABLE "terms" ADD COLUMN "source" varchar(50);--> statement-breakpoint
ALTER TABLE "terms" ADD COLUMN "source_id" text;--> statement-breakpoint
ALTER TABLE "translations" ADD COLUMN "cefr_level" varchar(2);--> statement-breakpoint
ALTER TABLE "term_topics" ADD CONSTRAINT "term_topics_term_id_terms_id_fk" FOREIGN KEY ("term_id") REFERENCES "public"."terms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "term_topics" ADD CONSTRAINT "term_topics_topic_id_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."topics"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_decks_type" ON "decks" USING btree ("type","source_language");--> statement-breakpoint
CREATE INDEX "idx_terms_source_pos" ON "terms" USING btree ("source","pos");--> statement-breakpoint
CREATE INDEX "idx_translations_lang" ON "translations" USING btree ("language_code","cefr_level","term_id");--> statement-breakpoint
ALTER TABLE "deck_terms" DROP COLUMN "added_at";--> statement-breakpoint
ALTER TABLE "decks" DROP COLUMN "is_public";--> statement-breakpoint
ALTER TABLE "terms" DROP COLUMN "synset_id";--> statement-breakpoint
ALTER TABLE "terms" ADD CONSTRAINT "unique_source_id" UNIQUE("source","source_id");--> statement-breakpoint
ALTER TABLE "decks" ADD CONSTRAINT "deck_type_check" CHECK ("decks"."type" IN ('grammar', 'media'));--> statement-breakpoint
ALTER TABLE "term_glosses" ADD CONSTRAINT "language_code_check" CHECK ("term_glosses"."language_code" IN ('en', 'it'));--> statement-breakpoint
ALTER TABLE "terms" ADD CONSTRAINT "pos_check" CHECK ("terms"."pos" IN ('noun', 'verb'));--> statement-breakpoint
ALTER TABLE "translations" ADD CONSTRAINT "language_code_check" CHECK ("translations"."language_code" IN ('en', 'it'));--> statement-breakpoint
ALTER TABLE "translations" ADD CONSTRAINT "cefr_check" CHECK ("translations"."cefr_level" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2'));

View file

@ -1,4 +0,0 @@
DROP INDEX "idx_translations_lang";--> statement-breakpoint
ALTER TABLE "translations" ADD COLUMN "difficulty" varchar(20);--> statement-breakpoint
CREATE INDEX "idx_translations_lang" ON "translations" USING btree ("language_code","difficulty","cefr_level","term_id");--> statement-breakpoint
ALTER TABLE "translations" ADD CONSTRAINT "difficulty_check" CHECK ("translations"."difficulty" IN ('easy', 'intermediate', 'hard'));

View file

@ -1,55 +0,0 @@
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

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

View file

@ -1,557 +0,0 @@
{
"id": "9ef7c86d-9e64-42d6-9731-2c1794ab063e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"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
},
"added_at": {
"name": "added_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_deck_terms_term": {
"name": "idx_deck_terms_term",
"columns": [
{
"expression": "term_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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": "'{}'"
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"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\"))"
}
},
"isRLSEnabled": false
},
"public.language_pairs": {
"name": "language_pairs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"source_language": {
"name": "source_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"target_language": {
"name": "target_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_pairs_active": {
"name": "idx_pairs_active",
"columns": [
{
"expression": "active",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "source_language",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "target_language",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_source_target": {
"name": "unique_source_target",
"nullsNotDistinct": false,
"columns": ["source_language", "target_language"]
}
},
"policies": {},
"checkConstraints": {
"source_language_check": {
"name": "source_language_check",
"value": "\"language_pairs\".\"source_language\" IN ('en', 'it')"
},
"target_language_check": {
"name": "target_language_check",
"value": "\"language_pairs\".\"target_language\" IN ('en', 'it')"
},
"no_self_pair": {
"name": "no_self_pair",
"value": "\"language_pairs\".\"source_language\" != \"language_pairs\".\"target_language\""
}
},
"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": {
"idx_term_glosses_term": {
"name": "idx_term_glosses_term",
"columns": [
{
"expression": "term_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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", "text"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.terms": {
"name": "terms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"synset_id": {
"name": "synset_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"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_pos": {
"name": "idx_terms_pos",
"columns": [
{
"expression": "pos",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"terms_synset_id_unique": {
"name": "terms_synset_id_unique",
"nullsNotDistinct": false,
"columns": ["synset_id"]
}
},
"policies": {},
"checkConstraints": {
"pos_check": {
"name": "pos_check",
"value": "\"terms\".\"pos\" IN ('noun')"
}
},
"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
},
"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": "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": {},
"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
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}

View file

@ -1,541 +0,0 @@
{
"id": "07a8aed0-8329-46d3-b70a-e21252597287",
"prevId": "9ef7c86d-9e64-42d6-9731-2c1794ab063e",
"version": "7",
"dialect": "postgresql",
"tables": {
"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
},
"added_at": {
"name": "added_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"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": "'{}'"
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"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\"))"
}
},
"isRLSEnabled": false
},
"public.language_pairs": {
"name": "language_pairs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"source_language": {
"name": "source_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"target_language": {
"name": "target_language",
"type": "varchar(10)",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active": {
"name": "active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_pairs_active": {
"name": "idx_pairs_active",
"columns": [
{
"expression": "active",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "source_language",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "target_language",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"unique_source_target": {
"name": "unique_source_target",
"nullsNotDistinct": false,
"columns": ["source_language", "target_language"]
}
},
"policies": {},
"checkConstraints": {
"source_language_check": {
"name": "source_language_check",
"value": "\"language_pairs\".\"source_language\" IN ('en', 'it')"
},
"target_language_check": {
"name": "target_language_check",
"value": "\"language_pairs\".\"target_language\" IN ('en', 'it')"
},
"no_self_pair": {
"name": "no_self_pair",
"value": "\"language_pairs\".\"source_language\" != \"language_pairs\".\"target_language\""
}
},
"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": {
"idx_term_glosses_term": {
"name": "idx_term_glosses_term",
"columns": [
{
"expression": "term_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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", "text"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.terms": {
"name": "terms",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"synset_id": {
"name": "synset_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"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_pos": {
"name": "idx_terms_pos",
"columns": [
{
"expression": "pos",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"terms_synset_id_unique": {
"name": "terms_synset_id_unique",
"nullsNotDistinct": false,
"columns": ["synset_id"]
}
},
"policies": {},
"checkConstraints": {
"pos_check": {
"name": "pos_check",
"value": "\"terms\".\"pos\" IN ('noun')"
}
},
"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
},
"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": "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": {},
"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
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}

View file

@ -1,582 +0,0 @@
{
"id": "a6e361d8-597a-4a34-be54-9d87bcb61437",
"prevId": "07a8aed0-8329-46d3-b70a-e21252597287",
"version": "7",
"dialect": "postgresql",
"tables": {
"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.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", "text"]
}
},
"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
},
"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": "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')"
}
},
"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
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}

View file

@ -1,598 +0,0 @@
{
"id": "8b22765b-67bb-4bc3-9549-4206ca080343",
"prevId": "a6e361d8-597a-4a34-be54-9d87bcb61437",
"version": "7",
"dialect": "postgresql",
"tables": {
"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.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", "text"]
}
},
"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.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
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
}

View file

@ -1,941 +0,0 @@
{
"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

@ -1,935 +0,0 @@
{
"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

@ -1,48 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775053965903,
"tag": "0000_faithful_oracle",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1775137476647,
"tag": "0001_clear_master_chief",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1775408266218,
"tag": "0002_perfect_arclight",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"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,28 +0,0 @@
{
"name": "@lila/db",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate",
"db:seed": "npx tsx src/seeding-datafiles.ts",
"db:build-deck": "npx tsx src/generating-deck.ts"
},
"dependencies": {
"@lila/shared": "workspace:*",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"pg": "^8.20.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/pg": "^8.20.0",
"drizzle-kit": "^0.31.10"
},
"exports": {
".": "./dist/src/index.js",
"./schema": "./dist/src/db/schema.js"
}
}

View file

@ -1,183 +0,0 @@
/*
This script performs a cross-reference check between two specific data sets:
- The "Target" List: It reads the {language}-merged.json file (e.g., en-merged.json). This represents the vocabulary you want to have CEFR levels for.
- The "Source of Truth": It queries your Database (translations table). This represents the vocabulary you currently have in your app.
What it calculates:
It tells you: "Of all the words in my merged JSON file, how many actually exist in my database?"
Matched: The word from the JSON file was found in the DB. (Ready for enrichment).
Unmatched: The word from the JSON file was not found in the DB. (These will be skipped during enrichment).
*/
import fs from "node:fs/promises";
import { eq } from "drizzle-orm";
import {
SUPPORTED_LANGUAGE_CODES,
SUPPORTED_POS,
CEFR_LEVELS,
DIFFICULTY_LEVELS,
} 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];
type CEFRLevel = (typeof CEFR_LEVELS)[number];
type Difficulty = (typeof DIFFICULTY_LEVELS)[number];
type MergedRecord = {
word: string;
pos: POS;
cefr: CEFRLevel;
difficulty: Difficulty;
sources: string[];
};
type CoverageStats = {
total: number;
matched: number;
unmatched: number;
byCefr: Record<CEFRLevel, { total: number; matched: number }>;
byDifficulty: Record<Difficulty, { total: number; matched: number }>;
unmatchedWords: Array<{ word: string; pos: POS; cefr: CEFRLevel }>;
};
const dataDir = "./src/data/";
async function checkCoverage(language: LanguageCode): Promise<void> {
const filename = `${language}-merged.json`;
const filepath = dataDir + filename;
console.log(`\n📄 Checking ${filename}...`);
// Load merged data
let records: MergedRecord[];
try {
const raw = await fs.readFile(filepath, "utf8");
records = JSON.parse(raw) as MergedRecord[];
} catch (e) {
console.warn(` ⚠️ Could not read file: ${(e as Error).message}`);
return;
}
console.log(` Loaded ${records.length.toLocaleString("en-US")} entries`);
// Initialize stats
const stats: CoverageStats = {
total: records.length,
matched: 0,
unmatched: 0,
byCefr: {} as Record<CEFRLevel, { total: number; matched: number }>,
byDifficulty: {} as Record<Difficulty, { total: number; matched: number }>,
unmatchedWords: [],
};
for (const level of CEFR_LEVELS)
stats.byCefr[level] = { total: 0, matched: 0 };
for (const diff of DIFFICULTY_LEVELS)
stats.byDifficulty[diff] = { total: 0, matched: 0 };
// ── BATCHED LOOKUP: Build a Set of existing (word, pos) pairs in DB ──
console.log(` 🔍 Querying database for existing translations...`);
// Get all existing translations for this language + POS combo
const existingRows = await db
.select({ text: translations.text, pos: terms.pos })
.from(translations)
.innerJoin(terms, eq(translations.term_id, terms.id))
.where(eq(translations.language_code, language));
// Create a Set for O(1) lookup: "word|pos" -> true
const existingSet = new Set(
existingRows.map((row) => `${row.text.toLowerCase()}|${row.pos}`),
);
// ── Process records against the in-memory Set ──
for (const record of records) {
stats.byCefr[record.cefr].total++;
stats.byDifficulty[record.difficulty].total++;
const key = `${record.word.toLowerCase()}|${record.pos}`;
if (existingSet.has(key)) {
stats.matched++;
stats.byCefr[record.cefr].matched++;
stats.byDifficulty[record.difficulty].matched++;
} else {
stats.unmatched++;
if (stats.unmatchedWords.length < 20) {
stats.unmatchedWords.push({
word: record.word,
pos: record.pos,
cefr: record.cefr,
});
}
}
}
// ── Print results (same as your draft) ──
console.log(`\n📊 Coverage for ${language}:`);
console.log(` Total entries: ${stats.total.toLocaleString("en-US")}`);
console.log(
` Matched in DB: ${stats.matched.toLocaleString("en-US")} (${((stats.matched / stats.total) * 100).toFixed(1)}%)`,
);
console.log(
` Unmatched: ${stats.unmatched.toLocaleString("en-US")} (${((stats.unmatched / stats.total) * 100).toFixed(1)}%)`,
);
console.log(`\n By CEFR level:`);
for (const level of CEFR_LEVELS) {
const { total, matched } = stats.byCefr[level];
if (total > 0) {
const pct = ((matched / total) * 100).toFixed(1);
console.log(
` ${level}: ${matched.toLocaleString("en-US")}/${total.toLocaleString("en-US")} (${pct}%)`,
);
}
}
console.log(`\n By difficulty:`);
for (const diff of DIFFICULTY_LEVELS) {
const { total, matched } = stats.byDifficulty[diff];
if (total > 0) {
const pct = ((matched / total) * 100).toFixed(1);
console.log(
` ${diff}: ${matched.toLocaleString("en-US")}/${total.toLocaleString("en-US")} (${pct}%)`,
);
}
}
if (stats.unmatchedWords.length > 0) {
console.log(`\n⚠ Sample unmatched words (first 20):`);
for (const { word, pos, cefr } of stats.unmatchedWords) {
console.log(` "${word}" (${pos}, ${cefr})`);
}
if (stats.unmatched > 20) {
console.log(` ... and ${stats.unmatched - 20} more`);
}
}
}
const main = async () => {
console.log("##########################################");
console.log("lila — CEFR Coverage Check");
console.log("##########################################");
for (const language of SUPPORTED_LANGUAGE_CODES) {
await checkCoverage(language);
}
console.log("\n##########################################");
console.log("Done");
console.log("##########################################");
};
main().catch((err) => {
console.error(err);
process.exit(1);
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,263 +0,0 @@
import {
pgTable,
text,
uuid,
timestamp,
varchar,
unique,
check,
primaryKey,
index,
boolean,
} from "drizzle-orm/pg-core";
import { sql, relations } from "drizzle-orm";
import {
SUPPORTED_POS,
SUPPORTED_LANGUAGE_CODES,
CEFR_LEVELS,
SUPPORTED_DECK_TYPES,
DIFFICULTY_LEVELS,
} from "@lila/shared";
export const terms = pgTable(
"terms",
{
id: uuid().primaryKey().defaultRandom(),
source: varchar({ length: 50 }), // 'omw', 'wiktionary', null for manual
source_id: text(), // synset_id value for omw, wiktionary QID, etc.
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(", "))})`,
),
unique("unique_source_id").on(table.source, table.source_id),
index("idx_terms_source_pos").on(table.source, table.pos),
],
);
export const term_glosses = pgTable(
"term_glosses",
{
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_term_gloss").on(table.term_id, table.language_code),
check(
"language_code_check",
sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
),
],
);
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(),
cefr_level: varchar({ length: 2 }),
difficulty: varchar({ length: 20 }),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
},
(table) => [
unique("unique_translations").on(
table.term_id,
table.language_code,
table.text,
),
check(
"language_code_check",
sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
),
check(
"cefr_check",
sql`${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`,
),
check(
"difficulty_check",
sql`${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`,
),
index("idx_translations_lang").on(
table.language_code,
table.difficulty,
table.cefr_level,
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([]),
type: varchar({ length: 20 }).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}))`,
),
check(
"deck_type_check",
sql`${table.type} IN (${sql.raw(SUPPORTED_DECK_TYPES.map((t) => `'${t}'`).join(", "))})`,
),
unique("unique_deck_name").on(table.name, table.source_language),
index("idx_decks_type").on(table.type, 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" }),
},
(table) => [primaryKey({ columns: [table.deck_id, table.term_id] })],
);
export const topics = pgTable("topics", {
id: uuid().primaryKey().defaultRandom(),
slug: varchar({ length: 50 }).notNull().unique(),
label: text().notNull(),
description: text(),
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
});
export const term_topics = pgTable(
"term_topics",
{
term_id: uuid()
.notNull()
.references(() => terms.id, { onDelete: "cascade" }),
topic_id: uuid()
.notNull()
.references(() => topics.id, { onDelete: "cascade" }),
},
(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
*
* source + source_id (terms): idempotency key per import pipeline
* display_name UNIQUE (users): multiplayer requires distinguishable names
* UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates
* updated_at omitted: misleading without a trigger to maintain it
* FK indexes: all FK columns covered, no sequential scans on joins
*/

View file

@ -1,211 +0,0 @@
import fs from "node:fs/promises";
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];
const config = {
pathToWordlist: "./src/data/wordlists/top1000englishnouns",
deckName: "top english nouns",
deckDescription: "Most frequently used English nouns for vocabulary practice",
sourceLanguage: "en",
sourcePOS: "noun",
} as const;
const readWordList = async () => {
const raw = await fs.readFile(config.pathToWordlist, "utf8");
const words = [
...new Set(
raw
.split("\n")
.map((w) => w.trim().toLowerCase())
.filter(Boolean),
),
];
return words;
};
const resolveSourceTerms = async (words: string[]) => {
const rows = await db
.select({ text: translations.text, termId: translations.term_id })
.from(translations)
.innerJoin(terms, eq(translations.term_id, terms.id))
.where(
and(
inArray(translations.text, words),
eq(translations.language_code, config.sourceLanguage),
eq(terms.pos, config.sourcePOS),
),
);
const wordToTermIds = new Map<string, string[]>();
for (const row of rows) {
const word = row.text.toLowerCase();
if (!wordToTermIds.has(word)) {
wordToTermIds.set(word, []);
}
wordToTermIds.get(word)!.push(row.termId);
}
// Deduplicate: multiple words can map to the same term ID (e.g. via synonyms)
const termIds = [...new Set(Array.from(wordToTermIds.values()).flat())];
const missingWords = words.filter((w) => !wordToTermIds.has(w));
return { termIds, missingWords };
};
const writeMissingWordsToFile = async (missingWords: string[]) => {
const outputPath = `${config.pathToWordlist}-missing`;
await fs.writeFile(outputPath, missingWords.join("\n"), "utf8");
};
const validateLanguages = async (sourceLanguage: string, termIds: string[]) => {
const coverage = await db
.select({
language: translations.language_code,
coveredCount: countDistinct(translations.term_id),
})
.from(translations)
.where(
and(
inArray(translations.term_id, termIds),
ne(translations.language_code, sourceLanguage),
),
)
.groupBy(translations.language_code);
const validatedLanguages = coverage
.filter((row) => Number(row.coveredCount) === termIds.length)
.map((row) => row.language);
return { coverage, validatedLanguages };
};
const findExistingDeck = async (tx: DbOrTx) => {
const existing = await tx
.select({ id: decks.id, validatedForLanguages: decks.validated_languages })
.from(decks)
.where(
and(
eq(decks.name, config.deckName),
eq(decks.source_language, config.sourceLanguage),
),
);
return existing[0] ?? null;
};
const createDeck = async (tx: DbOrTx, validatedLanguages: string[]) => {
const result = await tx
.insert(decks)
.values({
name: config.deckName,
description: config.deckDescription,
source_language: config.sourceLanguage,
validated_languages: validatedLanguages,
type: "core",
})
.returning({ id: decks.id });
const created = result[0];
if (!created) throw new Error("Failed to create deck: no row returned");
return created.id;
};
const addTermsToDeck = async (
tx: DbOrTx,
deckId: string,
termIds: string[],
): Promise<number> => {
if (termIds.length === 0) return 0;
await tx
.insert(deck_terms)
.values(termIds.map((termId) => ({ deck_id: deckId, term_id: termId })))
.onConflictDoNothing();
return termIds.length;
};
const updateValidatedLanguages = async (
tx: DbOrTx,
deckId: string,
validatedLanguages: string[],
): Promise<void> => {
await tx
.update(decks)
.set({ validated_languages: validatedLanguages })
.where(eq(decks.id, deckId));
};
const main = async () => {
console.log("📖 Reading word list...");
const sourceWords = await readWordList();
console.log(` ${sourceWords.length} words loaded\n`);
console.log("🔍 Checking against database...");
const { termIds, missingWords } = await resolveSourceTerms(sourceWords);
console.log(` ${termIds.length} terms found`);
console.log(` ${missingWords.length} words not found in DB\n`);
console.log("🖊️ Writing missing words to file...\n");
await writeMissingWordsToFile(missingWords);
console.log("✅ Validating languages...");
const { coverage, validatedLanguages } = await validateLanguages(
config.sourceLanguage,
termIds,
);
console.log(
` Validated languages: ${JSON.stringify(validatedLanguages)}\n`,
);
console.log("🔬 Language coverage breakdown...");
for (const row of coverage) {
console.log(
` ${row.language}: ${row.coveredCount} / ${termIds.length} terms covered`,
);
}
console.log("🃏 Looking for existing deck...");
const addedCount = await db.transaction(async (tx) => {
const existingDeck = await findExistingDeck(tx);
const deckId = existingDeck
? existingDeck.id
: await createDeck(tx, validatedLanguages);
const addedCount = await addTermsToDeck(tx, deckId, termIds);
const currentLanguages = existingDeck?.validatedForLanguages ?? [];
const hasChanged =
JSON.stringify([...currentLanguages].sort()) !==
JSON.stringify([...validatedLanguages].sort());
if (hasChanged) {
await updateValidatedLanguages(tx, deckId, validatedLanguages);
}
return addedCount;
});
const alreadyPresentCount = termIds.length - addedCount;
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("📊 Summary");
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log(` Words loaded from wordlist : ${sourceWords.length}`);
console.log(
` Words matched in DB : ${sourceWords.length - missingWords.length}`,
);
console.log(` Words not found in DB : ${missingWords.length}`);
console.log(` Term IDs resolved : ${termIds.length}`);
console.log(` Terms added to deck : ${addedCount}`);
console.log(` Terms already in deck : ${alreadyPresentCount}`);
console.log(
` Validated languages : ${validatedLanguages.length > 0 ? validatedLanguages.join(", ") : "none"}`,
);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
};
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -1,13 +0,0 @@
import { config } from "dotenv";
import { drizzle } from "drizzle-orm/node-postgres";
import { resolve } from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
config({
path: resolve(dirname(fileURLToPath(import.meta.url)), "../../../.env"),
});
export const db = drizzle(process.env["DATABASE_URL"]!);
export * from "./models/termModel.js";

View file

@ -1,114 +0,0 @@
import { db } from "@lila/db";
import { eq, and, isNotNull, sql, ne } from "drizzle-orm";
import { terms, translations, term_glosses } from "@lila/db/schema";
import { alias } from "drizzle-orm/pg-core";
import type {
SupportedLanguageCode,
SupportedPos,
DifficultyLevel,
} from "@lila/shared";
export type TranslationPairRow = {
termId: string;
sourceText: string;
targetText: string;
sourceGloss: string | null;
};
// Note: difficulty filter is intentionally asymmetric. We filter on the target
// (answer) side only — a word can be A2 in Italian but B1 in English, and what
// matters for the learner is the difficulty of the word they're being taught.
export const getGameTerms = async (
sourceLanguage: SupportedLanguageCode,
targetLanguage: SupportedLanguageCode,
pos: SupportedPos,
difficulty: DifficultyLevel,
rounds: number,
): Promise<TranslationPairRow[]> => {
const sourceTranslations = alias(translations, "source_translations");
const targetTranslations = alias(translations, "target_translations");
const rows = await db
.select({
termId: terms.id,
sourceText: sourceTranslations.text,
targetText: targetTranslations.text,
sourceGloss: term_glosses.text,
})
.from(terms)
.innerJoin(
sourceTranslations,
and(
eq(sourceTranslations.term_id, terms.id),
eq(sourceTranslations.language_code, sourceLanguage), // Filter here!
),
)
.innerJoin(
targetTranslations,
and(
eq(targetTranslations.term_id, terms.id),
eq(targetTranslations.language_code, targetLanguage), // Filter here!
),
)
.leftJoin(
term_glosses,
and(
eq(term_glosses.term_id, terms.id),
eq(term_glosses.language_code, sourceLanguage),
),
)
.where(
and(
eq(terms.pos, pos),
eq(targetTranslations.difficulty, difficulty),
isNotNull(sourceTranslations.difficulty), // Good data quality check!
),
)
// TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set before
// applying LIMIT, which is fine at current data volumes (low thousands of rows
// after POS + difficulty filters) but degrades as the terms table grows. Once
// the database is fully populated and tagged, replace with one of:
// - TABLESAMPLE BERNOULLI(n) for approximate sampling on large tables
// - Random offset: SELECT ... OFFSET floor(random() * (SELECT count(*) ...))
// - Pre-computed random column with a btree index, reshuffled periodically
// Benchmark first — don't optimise until it actually hurts.
.orderBy(sql`RANDOM()`)
.limit(rounds);
return rows;
};
export const getDistractors = async (
excludeTermId: string,
excludeText: string,
targetLanguage: SupportedLanguageCode,
pos: SupportedPos,
difficulty: DifficultyLevel,
count: number,
): Promise<string[]> => {
const rows = await db
.select({ text: translations.text })
.from(terms)
.innerJoin(
translations,
and(
eq(translations.term_id, terms.id),
eq(translations.language_code, targetLanguage),
),
)
.where(
and(
eq(terms.pos, pos),
eq(translations.difficulty, difficulty),
ne(terms.id, excludeTermId),
ne(translations.text, excludeText),
),
)
// TODO(post-mvp): same ORDER BY RANDOM() concern as getGameTerms — see comment there.
.orderBy(sql`RANDOM()`)
.limit(count);
return rows.map((row) => row.text);
};

View file

@ -1,148 +0,0 @@
import fs from "node:fs/promises";
import { eq, inArray } from "drizzle-orm";
import {
SUPPORTED_LANGUAGE_CODES,
SUPPORTED_POS,
CEFR_LEVELS,
DIFFICULTY_LEVELS,
} 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];
type CEFRLevel = (typeof CEFR_LEVELS)[number];
type Difficulty = (typeof DIFFICULTY_LEVELS)[number];
type MergedRecord = {
word: string;
pos: POS;
cefr: CEFRLevel;
difficulty: Difficulty;
sources: string[];
};
const dataDir = "./src/data/";
const BATCH_SIZE = 500;
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
function fmt(n: number): string {
return n.toLocaleString("en-US");
}
// ────────────────────────────────────────────────────────────
// Enrichment per language
// ────────────────────────────────────────────────────────────
async function enrichLanguage(language: LanguageCode): Promise<void> {
const filename = `${language}-merged.json`;
const filepath = dataDir + filename;
console.log(`\n📝 Enriching ${filename}...`);
let records: MergedRecord[];
try {
const raw = await fs.readFile(filepath, "utf8");
records = JSON.parse(raw) as MergedRecord[];
} catch (e) {
console.warn(` ⚠️ Could not read file: ${(e as Error).message}`);
return;
}
console.log(` Loaded ${fmt(records.length)} entries`);
// 1. Bulk fetch existing translations for this language
console.log(` 🔍 Fetching existing translations from DB...`);
const existingTranslations = await db
.select({ id: translations.id, text: translations.text, pos: terms.pos })
.from(translations)
.innerJoin(terms, eq(translations.term_id, terms.id))
.where(eq(translations.language_code, language));
// 2. Build lookup map: "lowercase_word|pos" -> translation IDs
const translationMap = new Map<string, string[]>();
for (const t of existingTranslations) {
const key = `${t.text.toLowerCase()}|${t.pos}`;
if (!translationMap.has(key)) translationMap.set(key, []);
translationMap.get(key)!.push(t.id);
}
// 3. Match records to DB IDs and group by target (cefr, difficulty)
const updatesByValue = new Map<string, string[]>();
const unmatchedWords: Array<{ word: string; pos: POS; cefr: CEFRLevel }> = [];
for (const rec of records) {
const key = `${rec.word.toLowerCase()}|${rec.pos}`;
const ids = translationMap.get(key);
if (ids && ids.length > 0) {
const valueKey = `${rec.cefr}|${rec.difficulty}`;
if (!updatesByValue.has(valueKey)) updatesByValue.set(valueKey, []);
updatesByValue.get(valueKey)!.push(...ids);
} else {
unmatchedWords.push({ word: rec.word, pos: rec.pos, cefr: rec.cefr });
}
}
// 4. Batch updates grouped by (cefr, difficulty)
let totalUpdated = 0;
for (const [valueKey, ids] of updatesByValue.entries()) {
const [cefr, difficulty] = valueKey.split("|") as [CEFRLevel, Difficulty];
const uniqueIds = [...new Set(ids)]; // Deduplicate synonyms/duplicates
for (const idBatch of chunk(uniqueIds, BATCH_SIZE)) {
await db
.update(translations)
.set({ cefr_level: cefr, difficulty })
.where(inArray(translations.id, idBatch));
totalUpdated += idBatch.length;
}
}
// 5. Summary
console.log(`\n ✅ Updated ${fmt(totalUpdated)} translations`);
console.log(` ⚠️ Unmatched: ${fmt(unmatchedWords.length)}`);
if (unmatchedWords.length > 0) {
console.log(`\n Sample unmatched words (first 20):`);
for (const { word, pos, cefr } of unmatchedWords.slice(0, 20)) {
console.log(` "${word}" (${pos}, ${cefr})`);
}
if (unmatchedWords.length > 20) {
console.log(` ... and ${fmt(unmatchedWords.length - 20)} more`);
}
}
}
// ────────────────────────────────────────────────────────────
// Main
// ────────────────────────────────────────────────────────────
const main = async () => {
console.log("##########################################");
console.log("lila — CEFR Enrichment");
console.log("##########################################\n");
for (const lang of SUPPORTED_LANGUAGE_CODES) {
await enrichLanguage(lang);
}
console.log("\n##########################################");
console.log("Done");
console.log("##########################################");
};
main().catch((err) => {
console.error(err);
process.exit(1);
});

Some files were not shown because too many files have changed in this diff Show more