Compare commits
No commits in common. "927ec14e2d67fb9e5fc9d055b5d5ea29304d4478" and "94f02b99049bad6e7d3f2e54cf975c67fb432b29" have entirely different histories.
927ec14e2d
...
94f02b9904
150 changed files with 471 additions and 6667767 deletions
|
|
@ -1,11 +0,0 @@
|
|||
**/node_modules
|
||||
**/dist
|
||||
**/build
|
||||
**/coverage
|
||||
|
||||
.env
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.git
|
||||
.gitignore
|
||||
*.tsbuildinfo
|
||||
12
.env.example
12
.env.example
|
|
@ -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=
|
||||
|
|
@ -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
11
.gitignore
vendored
|
|
@ -1,11 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
**/*.tsbuildinfo
|
||||
.repomixignore
|
||||
repomix.config.json
|
||||
repomix/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
|
@ -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
|
||||
13
.prettierrc
13
.prettierrc
|
|
@ -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"
|
||||
}
|
||||
11
Caddyfile
11
Caddyfile
|
|
@ -1,11 +0,0 @@
|
|||
lilastudy.com {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
api.lilastudy.com {
|
||||
reverse_proxy api:3000
|
||||
}
|
||||
|
||||
git.lilastudy.com {
|
||||
reverse_proxy forgejo:3000
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
# lila
|
||||
# glossa
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
||||
export { InMemoryGameSessionStore } from "./InMemoryGameSessionStore.js";
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -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" });
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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(),
|
||||
});
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({ test: { environment: "node", globals: true } });
|
||||
24
apps/web/.gitignore
vendored
24
apps/web/.gitignore
vendored
|
|
@ -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?
|
||||
|
|
@ -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
|
||||
|
|
@ -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...
|
||||
},
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>,
|
||||
);
|
||||
}
|
||||
|
|
@ -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>()
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
@ -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" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "../../packages/shared" },
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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" } },
|
||||
});
|
||||
|
|
@ -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
File diff suppressed because it is too large
Load diff
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 2–4 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 A1–C2 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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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())"
|
||||
```
|
||||
|
|
@ -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**: 2–4 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:** 2–4 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)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 English–Italian 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 English–Italian 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 English–Italian 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 2–4 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, 2–4 players answer simultaneously in real time, live scores, winner screen
|
||||
- 1000+ English–Italian 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 2–4 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 A1–C2)
|
||||
|
||||
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. English–French) 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 -- 1–1000, 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 (A1–C2) → 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 English–Italian 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
|
||||
- [ ] 10–20 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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
]);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[tools]
|
||||
node = "24.14.0"
|
||||
python = "latest"
|
||||
30
package.json
30
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]! },
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -1 +0,0 @@
|
|||
DROP INDEX "idx_deck_terms_term";
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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'));
|
||||
|
|
@ -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");
|
||||
|
|
@ -1 +0,0 @@
|
|||
DROP TABLE "users" CASCADE;
|
||||
|
|
@ -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": {} }
|
||||
}
|
||||
|
|
@ -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": {} }
|
||||
}
|
||||
|
|
@ -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": {} }
|
||||
}
|
||||
|
|
@ -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": {} }
|
||||
}
|
||||
|
|
@ -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": {} }
|
||||
}
|
||||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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
|
||||
*/
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue