From 2328ad445d3b6cf9e6b8d65d50a15ee3cdeb86ec Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 09:32:27 +0200 Subject: [PATCH 1/9] updating pnpm --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ed083f..d900474 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "format": "prettier --write .", "format:check": "prettier --check ." }, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@10.33.1", "devDependencies": { "@eslint/js": "^10.0.1", "@tanstack/eslint-plugin-router": "^1.161.6", From d67263e44a9aea2585ea8cef424c46afc137f039 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 09:33:11 +0200 Subject: [PATCH 2/9] updating file path --- packages/db/src/migrate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 075cf2f..5fd82ab 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", ), }); From cc0d2c7f8fd714b89f324fb40831d4cf358ff881 Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 09:39:18 +0200 Subject: [PATCH 3/9] removing dummy table for db migration pipeline test --- packages/db/drizzle/0010_thankful_reaper.sql | 1 + packages/db/drizzle/meta/0010_snapshot.json | 1184 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/db/schema.ts | 3 - 4 files changed, 1192 insertions(+), 3 deletions(-) create mode 100644 packages/db/drizzle/0010_thankful_reaper.sql create mode 100644 packages/db/drizzle/meta/0010_snapshot.json diff --git a/packages/db/drizzle/0010_thankful_reaper.sql b/packages/db/drizzle/0010_thankful_reaper.sql new file mode 100644 index 0000000..b542c2a --- /dev/null +++ b/packages/db/drizzle/0010_thankful_reaper.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..720a585 --- /dev/null +++ b/packages/db/drizzle/meta/0010_snapshot.json @@ -0,0 +1,1184 @@ +{ + "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 394f7e7..512887d 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "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 a79e64d..b2184b3 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -10,7 +10,6 @@ import { index, boolean, integer, - serial, } from "drizzle-orm/pg-core"; import { sql, relations } from "drizzle-orm"; @@ -331,5 +330,3 @@ 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() }); From 59049002fc670c08d3f88acc2c6dec8822252a2e Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 22:12:38 +0200 Subject: [PATCH 4/9] fix(api): skip rate limiting for non-sensitive auth endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authLimiter was blocking legitimate users because Better Auth's client polls /get-session frequently (on mount, route changes, focus), and /sign-out was also getting blocked after repeated session polls. Skip rate limiting for: - /get-session — read-only, requires valid cookie, no attack surface - /sign-out — no attack value in blocking logout - /callback/* — OAuth callbacks from providers Brute force protection remains on /sign-in, /sign-up, and other sensitive endpoints. --- apps/api/src/middleware/rateLimiters.test.ts | 39 +++++++++++++++----- apps/api/src/middleware/rateLimiters.ts | 9 +++++ 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/apps/api/src/middleware/rateLimiters.test.ts b/apps/api/src/middleware/rateLimiters.test.ts index 29a219f..3bcccbb 100644 --- a/apps/api/src/middleware/rateLimiters.test.ts +++ b/apps/api/src/middleware/rateLimiters.test.ts @@ -5,7 +5,6 @@ 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); @@ -20,29 +19,51 @@ 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", async () => { + it("allows requests under the limit through on sensitive endpoints", async () => { const res = await request(app).post("/api/auth/sign-in"); expect(res.status).toBe(200); }); - it("returns 429 after exceeding the limit", async () => { + it("returns 429 after exceeding the limit on sensitive endpoints", 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("sets RateLimit headers on responses", async () => { + 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 () => { 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 479be03..2f2eaf6 100644 --- a/apps/api/src/middleware/rateLimiters.ts +++ b/apps/api/src/middleware/rateLimiters.ts @@ -13,6 +13,15 @@ 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.", From ec84f76fb2354da6cf01913a0b806004cfe8adcd Mon Sep 17 00:00:00 2001 From: lila Date: Thu, 23 Apr 2026 23:32:30 +0200 Subject: [PATCH 5/9] updating backlog --- documentation/backlog.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/documentation/backlog.md b/documentation/backlog.md index 557ef1f..63b4bb5 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -8,9 +8,6 @@ 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. @@ -78,6 +75,9 @@ 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,6 +117,7 @@ Directionally right, timing is unclear. Revisit when the next/now work is done. Shipped milestones, newest first. +- **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 From 5b266d74354c33c2a967366a9e2c18151bac7430 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 24 Apr 2026 09:15:59 +0200 Subject: [PATCH 6/9] adding task to test gameservice --- documentation/backlog.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/documentation/backlog.md b/documentation/backlog.md index 63b4bb5..7656309 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -32,6 +32,9 @@ Things that are actively in progress or should be picked up immediately. Mostly - **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 From 762cf91f86cd8a2316a021c74db45b9f34316692 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 24 Apr 2026 09:30:20 +0200 Subject: [PATCH 7/9] updating tasks --- documentation/backlog.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/documentation/backlog.md b/documentation/backlog.md index 7656309..127ee05 100644 --- a/documentation/backlog.md +++ b/documentation/backlog.md @@ -26,9 +26,6 @@ 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. @@ -120,6 +117,7 @@ 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 From 4ece9953855fa8a779c621cbaf74abf85f897a35 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 24 Apr 2026 10:11:36 +0200 Subject: [PATCH 8/9] test: fill coverage gaps in lobbyService and gameService - joinLobby: addPlayer returns falsy (race condition fallback) - joinLobby: lobby disappears between addPlayer and final fetch - createLobby: non-unique-violation errors re-thrown immediately - createGameSession: unexpected DB errors propagate correctly --- apps/api/src/services/gameService.test.ts | 8 +++++++ apps/api/src/services/lobbyService.test.ts | 26 ++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index d1453f4..66b7470 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -126,6 +126,14 @@ 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 c998c12..c5de043 100644 --- a/apps/api/src/services/lobbyService.test.ts +++ b/apps/api/src/services/lobbyService.test.ts @@ -87,6 +87,14 @@ 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", () => { @@ -173,4 +181,22 @@ 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", + ); + }); }); From ee719aaa586feede2fe44a564b30c8804d3b2dc2 Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 24 Apr 2026 10:14:28 +0200 Subject: [PATCH 9/9] test: add test file for multiplayerGameService Covers generateMultiplayerQuestions: question count, option structure, correct answer inclusion, correctOptionId integrity, prompt/gloss passthrough, DB call arguments, and error propagation. --- .../services/multiplayerGameService.test.ts | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/api/src/services/multiplayerGameService.test.ts diff --git a/apps/api/src/services/multiplayerGameService.test.ts b/apps/api/src/services/multiplayerGameService.test.ts new file mode 100644 index 0000000..2261960 --- /dev/null +++ b/apps/api/src/services/multiplayerGameService.test.ts @@ -0,0 +1,125 @@ +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", + ); + }); +});