diff --git a/apps/api/src/middleware/rateLimiters.test.ts b/apps/api/src/middleware/rateLimiters.test.ts index 3bcccbb..29a219f 100644 --- a/apps/api/src/middleware/rateLimiters.test.ts +++ b/apps/api/src/middleware/rateLimiters.test.ts @@ -5,6 +5,7 @@ import { authLimiter, gameLimiter, lobbyLimiter } from "./rateLimiters.js"; import type { Session, User } from "better-auth"; +// Minimal app to test the limiter in isolation function createTestApp() { const app = express(); app.set("trust proxy", 1); @@ -19,51 +20,29 @@ describe("authLimiter", () => { let app: ReturnType; beforeEach(() => { + // Fresh app = fresh in-memory store = counters reset between tests app = createTestApp(); }); - it("allows requests under the limit through on sensitive endpoints", async () => { + it("allows requests under the limit through", async () => { const res = await request(app).post("/api/auth/sign-in"); expect(res.status).toBe(200); }); - it("returns 429 after exceeding the limit on sensitive endpoints", async () => { + it("returns 429 after exceeding the limit", async () => { const limit = 20; for (let i = 0; i < limit; i++) { await request(app).post("/api/auth/sign-in"); } const res = await request(app).post("/api/auth/sign-in"); expect(res.status).toBe(429); + expect(res.body).toEqual({ + success: false, + error: "Too many requests, please try again later.", + }); }); - it("does not rate limit /get-session", async () => { - const limit = 20; - for (let i = 0; i < limit + 5; i++) { - await request(app).get("/api/auth/get-session"); - } - const res = await request(app).get("/api/auth/get-session"); - expect(res.status).toBe(200); - }); - - it("does not rate limit /sign-out", async () => { - const limit = 20; - for (let i = 0; i < limit + 5; i++) { - await request(app).post("/api/auth/sign-out"); - } - const res = await request(app).post("/api/auth/sign-out"); - expect(res.status).toBe(200); - }); - - it("does not rate limit OAuth callbacks", async () => { - const limit = 20; - for (let i = 0; i < limit + 5; i++) { - await request(app).get("/api/auth/callback/google"); - } - const res = await request(app).get("/api/auth/callback/google"); - expect(res.status).toBe(200); - }); - - it("sets RateLimit headers on sensitive responses", async () => { + it("sets RateLimit headers on responses", async () => { const res = await request(app).post("/api/auth/sign-in"); expect(res.headers).toHaveProperty("ratelimit"); }); diff --git a/apps/api/src/middleware/rateLimiters.ts b/apps/api/src/middleware/rateLimiters.ts index 2f2eaf6..479be03 100644 --- a/apps/api/src/middleware/rateLimiters.ts +++ b/apps/api/src/middleware/rateLimiters.ts @@ -13,15 +13,6 @@ export const authLimiter = rateLimit({ limit: 20, standardHeaders: "draft-8", legacyHeaders: false, - skip: (req) => { - const path = req.path; - return ( - path.includes("/get-session") || - path.includes("/sign-out") || - path.startsWith("/callback/") || - path.includes("/callback/") - ); - }, message: { success: false, error: "Too many requests, please try again later.", diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 66b7470..d1453f4 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -126,14 +126,6 @@ describe("createGameSession", () => { expect(mockGetDistractors).toHaveBeenCalledTimes(3); }); - - it("propagates unexpected errors from getGameTerms", async () => { - mockGetGameTerms.mockRejectedValue(new Error("connection refused")); - - await expect(createGameSession(validRequest)).rejects.toThrow( - "connection refused", - ); - }); }); describe("evaluateAnswer", () => { diff --git a/apps/api/src/services/lobbyService.test.ts b/apps/api/src/services/lobbyService.test.ts index c5de043..c998c12 100644 --- a/apps/api/src/services/lobbyService.test.ts +++ b/apps/api/src/services/lobbyService.test.ts @@ -87,14 +87,6 @@ describe("createLobby", () => { "Could not generate a unique lobby code", ); }); - - it("re-throws non-unique-violation errors immediately", async () => { - const dbError = new Error("connection refused"); - mockCreateLobby.mockRejectedValue(dbError); - - await expect(createLobby("user-1")).rejects.toThrow("connection refused"); - expect(mockCreateLobby).toHaveBeenCalledTimes(1); - }); }); describe("joinLobby", () => { @@ -181,22 +173,4 @@ describe("joinLobby", () => { "Lobby is full", ); }); - - it("throws ConflictError when addPlayer returns falsy (race condition)", async () => { - mockAddPlayer.mockResolvedValue(undefined); - - await expect(joinLobby("ABC123", "user-2")).rejects.toThrow( - "Lobby is no longer available", - ); - }); - - it("throws AppError when lobby disappears after addPlayer succeeds", async () => { - mockGetLobbyByCodeWithPlayers - .mockResolvedValueOnce(fakeLobbyWithPlayers) - .mockResolvedValueOnce(undefined); - - await expect(joinLobby("ABC123", "user-2")).rejects.toThrow( - "Lobby disappeared during join", - ); - }); }); diff --git a/apps/api/src/services/multiplayerGameService.test.ts b/apps/api/src/services/multiplayerGameService.test.ts deleted file mode 100644 index 2261960..0000000 --- a/apps/api/src/services/multiplayerGameService.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() })); - -import { getGameTerms, getDistractors } from "@lila/db"; -import { generateMultiplayerQuestions } from "./multiplayerGameService.js"; - -const mockGetGameTerms = vi.mocked(getGameTerms); -const mockGetDistractors = vi.mocked(getDistractors); - -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("generateMultiplayerQuestions", () => { - it("returns the correct number of questions", async () => { - const questions = await generateMultiplayerQuestions(); - - expect(questions).toHaveLength(3); - }); - - it("each question has exactly 4 options", async () => { - const questions = await generateMultiplayerQuestions(); - - for (const question of questions) { - expect(question.options).toHaveLength(4); - } - }); - - it("each question has a unique questionId", async () => { - const questions = await generateMultiplayerQuestions(); - const ids = questions.map((q) => q.questionId); - - expect(new Set(ids).size).toBe(ids.length); - }); - - it("options have sequential optionIds 0-3", async () => { - const questions = await generateMultiplayerQuestions(); - - for (const question of 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 questions = await generateMultiplayerQuestions(); - - for (let i = 0; i < questions.length; i++) { - const question = questions[i]!; - const correctText = fakeTerms[i]!.targetText; - const optionTexts = question.options.map((o) => o.text); - - expect(optionTexts).toContain(correctText); - } - }); - - it("correctOptionId points to the option whose text matches the correct answer", async () => { - const questions = await generateMultiplayerQuestions(); - - for (let i = 0; i < questions.length; i++) { - const question = questions[i]!; - const correctText = fakeTerms[i]!.targetText; - const correctOption = question.options.find( - (o) => o.optionId === question.correctOptionId, - ); - - expect(correctOption?.text).toBe(correctText); - } - }); - - it("sets the prompt from the source text", async () => { - const questions = await generateMultiplayerQuestions(); - - expect(questions[0]!.prompt).toBe("dog"); - expect(questions[1]!.prompt).toBe("cat"); - expect(questions[2]!.prompt).toBe("house"); - }); - - it("passes gloss through (null or string)", async () => { - const questions = await generateMultiplayerQuestions(); - - expect(questions[0]!.gloss).toBeNull(); - expect(questions[2]!.gloss).toBe("a building for living in"); - }); - - it("calls getGameTerms with the multiplayer defaults", async () => { - await generateMultiplayerQuestions(); - - expect(mockGetGameTerms).toHaveBeenCalledWith( - "en", - "it", - "noun", - "easy", - 3, - ); - }); - - it("calls getDistractors once per question", async () => { - await generateMultiplayerQuestions(); - - expect(mockGetDistractors).toHaveBeenCalledTimes(3); - }); - - it("propagates unexpected errors from getGameTerms", async () => { - mockGetGameTerms.mockRejectedValue(new Error("connection refused")); - - await expect(generateMultiplayerQuestions()).rejects.toThrow( - "connection refused", - ); - }); -}); diff --git a/documentation/backlog.md b/documentation/backlog.md index 127ee05..557ef1f 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -8,6 +8,9 @@ Labels: `[feature]` `[infra]` `[security]` `[ux]` `[debt]` Things that are actively in progress or should be picked up immediately. Mostly operational risk and the remaining phase 7 hardening work. +- **Rate limiting on API endpoints** `[security]` + At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention). Consider `express-rate-limit`. + - **404 and redirect handling** `[ux]` Unknown routes return raw errors. Add a catch-all route on the frontend for client-side 404s. Consider a Caddy fallback for unrecognized subdomains. @@ -26,12 +29,12 @@ Things that are actively in progress or should be picked up immediately. Mostly - **Hetzner domain migration check** `[infra]` Verify whether the lilastudy.com domain needs to be migrated following a Hetzner DNS change. Check Hetzner dashboard for any pending migration notice. +- **Security headers with helmet** `[security]` + Add helmet middleware to set secure HTTP response headers. One-liner: app.use(helmet()). Covers headers like X-Content-Type-Options, X-Frame-Options, and Content-Security-Policy. + - **Conditionally register OAuth providers** `[debt]` Better Auth logs warnings when social providers are registered without credentials (`Social provider google is missing clientId or clientSecret`). Instead of registering all providers unconditionally, only add a provider to the config when its credentials are present in the environment. Keeps local dev clean for contributors who don't have OAuth apps set up. -- **Multiplayer GameService unit tests** `[debt]` - round evaluation, scoring, tie-breaking, timeout handling - --- ## next @@ -75,9 +78,6 @@ Clearly planned work, not yet started. No hard ordering — sequence based on wh - **Configurable game settings in multiplayer lobby** `[feature]` Game settings (mode, round count, timer duration, target score) are currently hardcoded. The host should be able to configure these when creating a lobby. Settings should be stored in the settings jsonb column on the lobbies table and passed through to the game service at start. -- **Tighten CSP to remove unsafe-inline** `[security]` - Current script-src uses 'unsafe-inline' to accommodate framework-injected inline scripts (likely TanStack Router hydration). Tightening this would require nonce-based CSP, which needs server-rendered HTML or a Caddy layer that injects per-request nonces. Not urgent — pragmatic CSP with 'unsafe-inline' is mainstream for SPAs at this scale. Revisit if the app handles more sensitive data or grows a meaningful user base - --- ## later @@ -117,8 +117,6 @@ Directionally right, timing is unclear. Revisit when the next/now work is done. Shipped milestones, newest first. -- **04 - 2026 - Security headers with helmet** - Add helmet middleware to set secure HTTP response headers. -- **04 - 2026 - Rate limiting on API endpoints** - At minimum: auth endpoints (brute force prevention) and game endpoints (spam prevention) - **04 - 2026 — Migrations in deploy pipeline** — Drizzle migrate runs as a CI/CD step before the API container restarts - **04 - 2026 — Phase 6: Production deployment** — Hetzner VPS, Caddy HTTPS, Forgejo CI/CD, daily DB backups, cross-subdomain auth - **04 - 2026 — Phase 5: Multiplayer game** — real-time simultaneous play, 15s server timer, live scoring, winner screen diff --git a/package.json b/package.json index d900474..0ed083f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "format": "prettier --write .", "format:check": "prettier --check ." }, - "packageManager": "pnpm@10.33.1", + "packageManager": "pnpm@10.33.0", "devDependencies": { "@eslint/js": "^10.0.1", "@tanstack/eslint-plugin-router": "^1.161.6", diff --git a/packages/db/drizzle/0010_thankful_reaper.sql b/packages/db/drizzle/0010_thankful_reaper.sql deleted file mode 100644 index b542c2a..0000000 --- a/packages/db/drizzle/0010_thankful_reaper.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE "dummy" CASCADE; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0010_snapshot.json b/packages/db/drizzle/meta/0010_snapshot.json deleted file mode 100644 index 720a585..0000000 --- a/packages/db/drizzle/meta/0010_snapshot.json +++ /dev/null @@ -1,1184 +0,0 @@ -{ - "id": "6c1cb049-807d-43d0-b83e-d3575b80de33", - "prevId": "24f8a0f9-40eb-4ad7-b08a-00ab7c98ecd4", - "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', 'de', 'fr', 'es')" - }, - "validated_languages_check": { - "name": "validated_languages_check", - "value": "validated_languages <@ ARRAY['en', 'it', 'de', 'fr', 'es']::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.lobbies": { - "name": "lobbies", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "code": { - "name": "code", - "type": "varchar(10)", - "primaryKey": false, - "notNull": true - }, - "host_user_id": { - "name": "host_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'waiting'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "lobbies_host_user_id_user_id_fk": { - "name": "lobbies_host_user_id_user_id_fk", - "tableFrom": "lobbies", - "tableTo": "user", - "columnsFrom": [ - "host_user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "lobbies_code_unique": { - "name": "lobbies_code_unique", - "nullsNotDistinct": false, - "columns": [ - "code" - ] - } - }, - "policies": {}, - "checkConstraints": { - "lobby_status_check": { - "name": "lobby_status_check", - "value": "\"lobbies\".\"status\" IN ('waiting', 'in_progress', 'finished')" - } - }, - "isRLSEnabled": false - }, - "public.lobby_players": { - "name": "lobby_players", - "schema": "", - "columns": { - "lobby_id": { - "name": "lobby_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "score": { - "name": "score", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "joined_at": { - "name": "joined_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "lobby_players_lobby_id_lobbies_id_fk": { - "name": "lobby_players_lobby_id_lobbies_id_fk", - "tableFrom": "lobby_players", - "tableTo": "lobbies", - "columnsFrom": [ - "lobby_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "lobby_players_user_id_user_id_fk": { - "name": "lobby_players_user_id_user_id_fk", - "tableFrom": "lobby_players", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "lobby_players_lobby_id_user_id_pk": { - "name": "lobby_players_lobby_id_user_id_pk", - "columns": [ - "lobby_id", - "user_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "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_examples": { - "name": "term_examples", - "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_examples_term_id": { - "name": "idx_term_examples_term_id", - "columns": [ - { - "expression": "term_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "language_code", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "term_examples_term_id_terms_id_fk": { - "name": "term_examples_term_id_terms_id_fk", - "tableFrom": "term_examples", - "tableTo": "terms", - "columnsFrom": [ - "term_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "unique_term_example": { - "name": "unique_term_example", - "nullsNotDistinct": false, - "columns": [ - "term_id", - "language_code", - "text" - ] - } - }, - "policies": {}, - "checkConstraints": { - "language_code_check": { - "name": "language_code_check", - "value": "\"term_examples\".\"language_code\" IN ('en', 'it', 'de', 'fr', 'es')" - } - }, - "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 - }, - "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": { - "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', 'de', 'fr', 'es')" - } - }, - "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', 'adjective', 'adverb')" - } - }, - "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', 'de', 'fr', 'es')" - }, - "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": {} - } -} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 512887d..394f7e7 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -71,13 +71,6 @@ "when": 1776928720684, "tag": "0009_rapid_cobalt_man", "breakpoints": true - }, - { - "idx": 10, - "version": "7", - "when": 1776929932845, - "tag": "0010_thankful_reaper", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index b2184b3..a79e64d 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -10,6 +10,7 @@ import { index, boolean, integer, + serial, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -330,3 +331,5 @@ export const lobbyPlayersRelations = relations(lobby_players, ({ one }) => ({ }), user: one(user, { fields: [lobby_players.userId], references: [user.id] }), })); + +export const dummy = pgTable("dummy", { id: serial("id").primaryKey() }); diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 5fd82ab..075cf2f 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -17,7 +17,7 @@ console.log("starting database migrations..."); await migrate(db, { migrationsFolder: resolve( dirname(fileURLToPath(import.meta.url)), - "../../drizzle", + "../drizzle", ), });