feat: migrate production schema from OMW to Kaikki flat vocabulary model
- Replace terms/translations/term_glosses/term_examples with vocabulary_entries and entry_translations - Remove decks, topics and related tables (deferred) - Add cefr_level and difficulty to entry_translations for game query filtering - Update termModel.ts for new schema — getDistractors now takes sourceLanguage - Update gameService.ts and multiplayerGameService.ts for entryId rename - Update all test fixtures from termId to entryId - Generate and apply migration 0011
This commit is contained in:
parent
38d8b85228
commit
963bff4eb8
10 changed files with 949 additions and 215 deletions
|
|
@ -64,9 +64,14 @@ const validBody = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
{ entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
||||||
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
{ entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
||||||
{ termId: "t3", sourceText: "house", targetText: "casa", sourceGloss: null },
|
{
|
||||||
|
entryId: "t3",
|
||||||
|
sourceText: "house",
|
||||||
|
targetText: "casa",
|
||||||
|
sourceGloss: "a building for living in",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@ const validRequest: GameRequest = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
{ entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
||||||
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
{ entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
||||||
{
|
{
|
||||||
termId: "t3",
|
entryId: "t3",
|
||||||
sourceText: "house",
|
sourceText: "house",
|
||||||
targetText: "casa",
|
targetText: "casa",
|
||||||
sourceGloss: "a building for living in",
|
sourceGloss: "a building for living in",
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,9 @@ export const createGameSession = async (
|
||||||
const questions: GameQuestion[] = await Promise.all(
|
const questions: GameQuestion[] = await Promise.all(
|
||||||
terms.map(async (term) => {
|
terms.map(async (term) => {
|
||||||
const distractorTexts = await getDistractors(
|
const distractorTexts = await getDistractors(
|
||||||
term.termId,
|
term.entryId,
|
||||||
term.targetText,
|
term.targetText,
|
||||||
|
request.source_language,
|
||||||
request.target_language,
|
request.target_language,
|
||||||
request.pos,
|
request.pos,
|
||||||
request.difficulty,
|
request.difficulty,
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ const mockGetGameTerms = vi.mocked(getGameTerms);
|
||||||
const mockGetDistractors = vi.mocked(getDistractors);
|
const mockGetDistractors = vi.mocked(getDistractors);
|
||||||
|
|
||||||
const fakeTerms = [
|
const fakeTerms = [
|
||||||
{ termId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
{ entryId: "t1", sourceText: "dog", targetText: "cane", sourceGloss: null },
|
||||||
{ termId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
{ entryId: "t2", sourceText: "cat", targetText: "gatto", sourceGloss: null },
|
||||||
{
|
{
|
||||||
termId: "t3",
|
entryId: "t3",
|
||||||
sourceText: "house",
|
sourceText: "house",
|
||||||
targetText: "casa",
|
targetText: "casa",
|
||||||
sourceGloss: "a building for living in",
|
sourceGloss: "a building for living in",
|
||||||
|
|
|
||||||
|
|
@ -44,8 +44,9 @@ export const generateMultiplayerQuestions = async (): Promise<
|
||||||
const questions: MultiplayerQuestion[] = await Promise.all(
|
const questions: MultiplayerQuestion[] = await Promise.all(
|
||||||
correctAnswers.map(async (correctAnswer) => {
|
correctAnswers.map(async (correctAnswer) => {
|
||||||
const distractorTexts = await getDistractors(
|
const distractorTexts = await getDistractors(
|
||||||
correctAnswer.termId,
|
correctAnswer.entryId,
|
||||||
correctAnswer.targetText,
|
correctAnswer.targetText,
|
||||||
|
MULTIPLAYER_DEFAULTS.sourceLanguage,
|
||||||
MULTIPLAYER_DEFAULTS.targetLanguage,
|
MULTIPLAYER_DEFAULTS.targetLanguage,
|
||||||
MULTIPLAYER_DEFAULTS.pos,
|
MULTIPLAYER_DEFAULTS.pos,
|
||||||
MULTIPLAYER_DEFAULTS.difficulty,
|
MULTIPLAYER_DEFAULTS.difficulty,
|
||||||
|
|
|
||||||
46
packages/db/drizzle/0011_nice_spyke.sql
Normal file
46
packages/db/drizzle/0011_nice_spyke.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
CREATE TABLE "entry_translations" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"entry_id" uuid NOT NULL,
|
||||||
|
"target_language_code" varchar(10) NOT NULL,
|
||||||
|
"translation" text NOT NULL,
|
||||||
|
"sense_hint" text,
|
||||||
|
"cefr_level" varchar(2),
|
||||||
|
"difficulty" varchar(20),
|
||||||
|
"source" varchar(50) DEFAULT 'kaikki' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "unique_translation" UNIQUE("entry_id","target_language_code","translation"),
|
||||||
|
CONSTRAINT "target_language_code_check" CHECK ("entry_translations"."target_language_code" IN ('en', 'it', 'de', 'fr', 'es')),
|
||||||
|
CONSTRAINT "cefr_check" CHECK ("entry_translations"."cefr_level" IS NULL OR "entry_translations"."cefr_level" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')),
|
||||||
|
CONSTRAINT "difficulty_check" CHECK ("entry_translations"."difficulty" IS NULL OR "entry_translations"."difficulty" IN ('easy', 'intermediate', 'hard'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "vocabulary_entries" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"headword" text NOT NULL,
|
||||||
|
"language_code" varchar(10) NOT NULL,
|
||||||
|
"pos" varchar(20) NOT NULL,
|
||||||
|
"sense_index" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"gloss" text,
|
||||||
|
"examples" text[] DEFAULT '{}' NOT NULL,
|
||||||
|
"cefr_level" varchar(2),
|
||||||
|
"difficulty" varchar(20),
|
||||||
|
"source" varchar(50) DEFAULT 'kaikki' NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "unique_entry" UNIQUE("headword","language_code","pos","sense_index"),
|
||||||
|
CONSTRAINT "language_code_check" CHECK ("vocabulary_entries"."language_code" IN ('en', 'it', 'de', 'fr', 'es')),
|
||||||
|
CONSTRAINT "pos_check" CHECK ("vocabulary_entries"."pos" IN ('noun', 'verb', 'adjective', 'adverb')),
|
||||||
|
CONSTRAINT "cefr_check" CHECK ("vocabulary_entries"."cefr_level" IS NULL OR "vocabulary_entries"."cefr_level" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')),
|
||||||
|
CONSTRAINT "difficulty_check" CHECK ("vocabulary_entries"."difficulty" IS NULL OR "vocabulary_entries"."difficulty" IN ('easy', 'intermediate', 'hard'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
DROP TABLE "deck_terms" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "decks" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "term_examples" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "term_glosses" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "term_topics" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "terms" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "topics" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TABLE "translations" CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "entry_translations" ADD CONSTRAINT "entry_translations_entry_id_vocabulary_entries_id_fk" FOREIGN KEY ("entry_id") REFERENCES "public"."vocabulary_entries"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_translations_target_lang" ON "entry_translations" USING btree ("target_language_code","difficulty","entry_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_entries_lang_pos" ON "vocabulary_entries" USING btree ("language_code","pos","difficulty");
|
||||||
750
packages/db/drizzle/meta/0011_snapshot.json
Normal file
750
packages/db/drizzle/meta/0011_snapshot.json
Normal file
|
|
@ -0,0 +1,750 @@
|
||||||
|
{
|
||||||
|
"id": "6f1811a6-8573-4d43-912a-ceb5191341cc",
|
||||||
|
"prevId": "6c1cb049-807d-43d0-b83e-d3575b80de33",
|
||||||
|
"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.entry_translations": {
|
||||||
|
"name": "entry_translations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"entry_id": {
|
||||||
|
"name": "entry_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"target_language_code": {
|
||||||
|
"name": "target_language_code",
|
||||||
|
"type": "varchar(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"translation": {
|
||||||
|
"name": "translation",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sense_hint": {
|
||||||
|
"name": "sense_hint",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"cefr_level": {
|
||||||
|
"name": "cefr_level",
|
||||||
|
"type": "varchar(2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"name": "difficulty",
|
||||||
|
"type": "varchar(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'kaikki'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_translations_target_lang": {
|
||||||
|
"name": "idx_translations_target_lang",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "target_language_code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "difficulty",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "entry_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"entry_translations_entry_id_vocabulary_entries_id_fk": {
|
||||||
|
"name": "entry_translations_entry_id_vocabulary_entries_id_fk",
|
||||||
|
"tableFrom": "entry_translations",
|
||||||
|
"tableTo": "vocabulary_entries",
|
||||||
|
"columnsFrom": ["entry_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"unique_translation": {
|
||||||
|
"name": "unique_translation",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["entry_id", "target_language_code", "translation"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"target_language_code_check": {
|
||||||
|
"name": "target_language_code_check",
|
||||||
|
"value": "\"entry_translations\".\"target_language_code\" IN ('en', 'it', 'de', 'fr', 'es')"
|
||||||
|
},
|
||||||
|
"cefr_check": {
|
||||||
|
"name": "cefr_check",
|
||||||
|
"value": "\"entry_translations\".\"cefr_level\" IS NULL OR \"entry_translations\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')"
|
||||||
|
},
|
||||||
|
"difficulty_check": {
|
||||||
|
"name": "difficulty_check",
|
||||||
|
"value": "\"entry_translations\".\"difficulty\" IS NULL OR \"entry_translations\".\"difficulty\" IN ('easy', 'intermediate', 'hard')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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.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
|
||||||
|
},
|
||||||
|
"public.vocabulary_entries": {
|
||||||
|
"name": "vocabulary_entries",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"headword": {
|
||||||
|
"name": "headword",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"language_code": {
|
||||||
|
"name": "language_code",
|
||||||
|
"type": "varchar(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"pos": {
|
||||||
|
"name": "pos",
|
||||||
|
"type": "varchar(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"sense_index": {
|
||||||
|
"name": "sense_index",
|
||||||
|
"type": "smallint",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"gloss": {
|
||||||
|
"name": "gloss",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"examples": {
|
||||||
|
"name": "examples",
|
||||||
|
"type": "text[]",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'"
|
||||||
|
},
|
||||||
|
"cefr_level": {
|
||||||
|
"name": "cefr_level",
|
||||||
|
"type": "varchar(2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"difficulty": {
|
||||||
|
"name": "difficulty",
|
||||||
|
"type": "varchar(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"name": "source",
|
||||||
|
"type": "varchar(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'kaikki'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"idx_entries_lang_pos": {
|
||||||
|
"name": "idx_entries_lang_pos",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "language_code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "pos",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "difficulty",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"unique_entry": {
|
||||||
|
"name": "unique_entry",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["headword", "language_code", "pos", "sense_index"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {
|
||||||
|
"language_code_check": {
|
||||||
|
"name": "language_code_check",
|
||||||
|
"value": "\"vocabulary_entries\".\"language_code\" IN ('en', 'it', 'de', 'fr', 'es')"
|
||||||
|
},
|
||||||
|
"pos_check": {
|
||||||
|
"name": "pos_check",
|
||||||
|
"value": "\"vocabulary_entries\".\"pos\" IN ('noun', 'verb', 'adjective', 'adverb')"
|
||||||
|
},
|
||||||
|
"cefr_check": {
|
||||||
|
"name": "cefr_check",
|
||||||
|
"value": "\"vocabulary_entries\".\"cefr_level\" IS NULL OR \"vocabulary_entries\".\"cefr_level\" IN ('A1', 'A2', 'B1', 'B2', 'C1', 'C2')"
|
||||||
|
},
|
||||||
|
"difficulty_check": {
|
||||||
|
"name": "difficulty_check",
|
||||||
|
"value": "\"vocabulary_entries\".\"difficulty\" IS NULL OR \"vocabulary_entries\".\"difficulty\" IN ('easy', 'intermediate', 'hard')"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": { "columns": {}, "schemas": {}, "tables": {} }
|
||||||
|
}
|
||||||
|
|
@ -78,6 +78,13 @@
|
||||||
"when": 1776929932845,
|
"when": 1776929932845,
|
||||||
"tag": "0010_thankful_reaper",
|
"tag": "0010_thankful_reaper",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777994750330,
|
||||||
|
"tag": "0011_nice_spyke",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
index,
|
index,
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
|
smallint,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import { sql, relations } from "drizzle-orm";
|
import { sql, relations } from "drizzle-orm";
|
||||||
|
|
@ -18,182 +19,100 @@ import {
|
||||||
SUPPORTED_POS,
|
SUPPORTED_POS,
|
||||||
SUPPORTED_LANGUAGE_CODES,
|
SUPPORTED_LANGUAGE_CODES,
|
||||||
CEFR_LEVELS,
|
CEFR_LEVELS,
|
||||||
SUPPORTED_DECK_TYPES,
|
|
||||||
DIFFICULTY_LEVELS,
|
DIFFICULTY_LEVELS,
|
||||||
LOBBY_STATUSES,
|
LOBBY_STATUSES,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
|
|
||||||
export const terms = pgTable(
|
// ── Vocabulary ────────────────────────────────────────────────────────────────
|
||||||
"terms",
|
|
||||||
|
export const vocabulary_entries = pgTable(
|
||||||
|
"vocabulary_entries",
|
||||||
{
|
{
|
||||||
id: uuid().primaryKey().defaultRandom(),
|
id: uuid().primaryKey().defaultRandom(),
|
||||||
source: varchar({ length: 50 }), // 'omw', 'wiktionary', null for manual
|
headword: text().notNull(),
|
||||||
source_id: text(), // synset_id value for omw, wiktionary QID, etc.
|
language_code: varchar({ length: 10 }).notNull(),
|
||||||
pos: varchar({ length: 20 }).notNull(),
|
pos: varchar({ length: 20 }).notNull(),
|
||||||
|
sense_index: smallint().notNull().default(0),
|
||||||
|
gloss: text(),
|
||||||
|
examples: text().array().notNull().default([]),
|
||||||
|
cefr_level: varchar({ length: 2 }),
|
||||||
|
difficulty: varchar({ length: 20 }),
|
||||||
|
source: varchar({ length: 50 }).notNull().default("kaikki"),
|
||||||
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
|
unique("unique_entry").on(
|
||||||
|
table.headword,
|
||||||
|
table.language_code,
|
||||||
|
table.pos,
|
||||||
|
table.sense_index,
|
||||||
|
),
|
||||||
|
check(
|
||||||
|
"language_code_check",
|
||||||
|
sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
|
||||||
|
),
|
||||||
check(
|
check(
|
||||||
"pos_check",
|
"pos_check",
|
||||||
sql`${table.pos} IN (${sql.raw(SUPPORTED_POS.map((p) => `'${p}'`).join(", "))})`,
|
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(),
|
|
||||||
description: text(),
|
|
||||||
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 term_examples = pgTable(
|
|
||||||
"term_examples",
|
|
||||||
{
|
|
||||||
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_example").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(", "))})`,
|
|
||||||
),
|
|
||||||
index("idx_term_examples_term_id").on(table.term_id, table.language_code),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
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(
|
check(
|
||||||
"cefr_check",
|
"cefr_check",
|
||||||
sql`${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`,
|
sql`${table.cefr_level} IS NULL OR ${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`,
|
||||||
),
|
),
|
||||||
check(
|
check(
|
||||||
"difficulty_check",
|
"difficulty_check",
|
||||||
sql`${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`,
|
sql`${table.difficulty} IS NULL OR ${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`,
|
||||||
),
|
),
|
||||||
index("idx_translations_lang").on(
|
index("idx_entries_lang_pos").on(
|
||||||
table.language_code,
|
table.language_code,
|
||||||
|
table.pos,
|
||||||
table.difficulty,
|
table.difficulty,
|
||||||
table.cefr_level,
|
|
||||||
table.term_id,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const decks = pgTable(
|
export const entry_translations = pgTable(
|
||||||
"decks",
|
"entry_translations",
|
||||||
{
|
{
|
||||||
id: uuid().primaryKey().defaultRandom(),
|
id: uuid().primaryKey().defaultRandom(),
|
||||||
name: text().notNull(),
|
entry_id: uuid()
|
||||||
description: text(),
|
.notNull()
|
||||||
source_language: varchar({ length: 10 }).notNull(),
|
.references(() => vocabulary_entries.id, { onDelete: "cascade" }),
|
||||||
validated_languages: varchar({ length: 10 }).array().notNull().default([]),
|
target_language_code: varchar({ length: 10 }).notNull(),
|
||||||
type: varchar({ length: 20 }).notNull(),
|
translation: text().notNull(),
|
||||||
|
sense_hint: text(),
|
||||||
|
cefr_level: varchar({ length: 2 }),
|
||||||
|
difficulty: varchar({ length: 20 }),
|
||||||
|
source: varchar({ length: 50 }).notNull().default("kaikki"),
|
||||||
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
check(
|
unique("unique_translation").on(
|
||||||
"source_language_check",
|
table.entry_id,
|
||||||
sql`${table.source_language} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
|
table.target_language_code,
|
||||||
|
table.translation,
|
||||||
),
|
),
|
||||||
check(
|
check(
|
||||||
"validated_languages_check",
|
"target_language_code_check",
|
||||||
sql`validated_languages <@ ARRAY[${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))}]::varchar[]`,
|
sql`${table.target_language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
|
||||||
),
|
),
|
||||||
check(
|
check(
|
||||||
"validated_languages_excludes_source",
|
"cefr_check",
|
||||||
sql`NOT (${table.source_language} = ANY(${table.validated_languages}))`,
|
sql`${table.cefr_level} IS NULL OR ${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`,
|
||||||
),
|
),
|
||||||
check(
|
check(
|
||||||
"deck_type_check",
|
"difficulty_check",
|
||||||
sql`${table.type} IN (${sql.raw(SUPPORTED_DECK_TYPES.map((t) => `'${t}'`).join(", "))})`,
|
sql`${table.difficulty} IS NULL OR ${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((d) => `'${d}'`).join(", "))})`,
|
||||||
|
),
|
||||||
|
index("idx_translations_target_lang").on(
|
||||||
|
table.target_language_code,
|
||||||
|
table.difficulty,
|
||||||
|
table.entry_id,
|
||||||
),
|
),
|
||||||
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(
|
// ── Auth (managed by Better Auth) ─────────────────────────────────────────────
|
||||||
"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", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
@ -204,7 +123,7 @@ export const user = pgTable("user", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -216,7 +135,7 @@ export const session = pgTable(
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ipAddress: text("ip_address"),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
|
|
@ -245,7 +164,7 @@ export const account = pgTable(
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("account_userId_idx").on(table.userId)],
|
(table) => [index("account_userId_idx").on(table.userId)],
|
||||||
|
|
@ -261,24 +180,13 @@ export const verification = pgTable(
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
// ── Lobbies ───────────────────────────────────────────────────────────────────
|
||||||
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] }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const lobbies = pgTable(
|
export const lobbies = pgTable(
|
||||||
"lobbies",
|
"lobbies",
|
||||||
|
|
@ -318,6 +226,36 @@ export const lobby_players = pgTable(
|
||||||
(table) => [primaryKey({ columns: [table.lobbyId, table.userId] })],
|
(table) => [primaryKey({ columns: [table.lobbyId, table.userId] })],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Relations ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const vocabularyEntryRelations = relations(
|
||||||
|
vocabulary_entries,
|
||||||
|
({ many }) => ({ translations: many(entry_translations) }),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const entryTranslationRelations = relations(
|
||||||
|
entry_translations,
|
||||||
|
({ one }) => ({
|
||||||
|
entry: one(vocabulary_entries, {
|
||||||
|
fields: [entry_translations.entry_id],
|
||||||
|
references: [vocabulary_entries.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const lobbyRelations = relations(lobbies, ({ one, many }) => ({
|
export const lobbyRelations = relations(lobbies, ({ one, many }) => ({
|
||||||
host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }),
|
host: one(user, { fields: [lobbies.hostUserId], references: [user.id] }),
|
||||||
players: many(lobby_players),
|
players: many(lobby_players),
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,27 @@
|
||||||
import { db } from "@lila/db";
|
import { db } from "@lila/db";
|
||||||
import { eq, and, isNotNull, sql, ne } from "drizzle-orm";
|
import { eq, and, ne, sql, isNotNull } from "drizzle-orm";
|
||||||
import { terms, translations, term_glosses } from "@lila/db/schema";
|
import { vocabulary_entries, entry_translations } from "@lila/db/schema";
|
||||||
import { alias } from "drizzle-orm/pg-core";
|
import { alias } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
SupportedLanguageCode,
|
SupportedLanguageCode,
|
||||||
SupportedPos,
|
SupportedPos,
|
||||||
DifficultyLevel,
|
DifficultyLevel,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type TranslationPairRow = {
|
export type TranslationPairRow = {
|
||||||
termId: string;
|
entryId: string;
|
||||||
sourceText: string;
|
sourceText: string;
|
||||||
targetText: string;
|
targetText: string;
|
||||||
sourceGloss: string | null;
|
sourceGloss: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Note: difficulty filter is intentionally asymmetric. We filter on the target
|
// ── Queries ───────────────────────────────────────────────────────────────────
|
||||||
// (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.
|
|
||||||
|
|
||||||
|
// Note: difficulty filter is intentionally on the target (translation) side.
|
||||||
|
// A word can be A2 in one language but B1 in another — what matters for the
|
||||||
|
// learner is the difficulty of the word they are being tested on.
|
||||||
export const getGameTerms = async (
|
export const getGameTerms = async (
|
||||||
sourceLanguage: SupportedLanguageCode,
|
sourceLanguage: SupportedLanguageCode,
|
||||||
targetLanguage: SupportedLanguageCode,
|
targetLanguage: SupportedLanguageCode,
|
||||||
|
|
@ -27,53 +29,36 @@ export const getGameTerms = async (
|
||||||
difficulty: DifficultyLevel,
|
difficulty: DifficultyLevel,
|
||||||
rounds: number,
|
rounds: number,
|
||||||
): Promise<TranslationPairRow[]> => {
|
): Promise<TranslationPairRow[]> => {
|
||||||
const sourceTranslations = alias(translations, "source_translations");
|
const sourceEntries = alias(vocabulary_entries, "source_entries");
|
||||||
const targetTranslations = alias(translations, "target_translations");
|
const targetTranslations = alias(entry_translations, "target_translations");
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
termId: terms.id,
|
entryId: sourceEntries.id,
|
||||||
sourceText: sourceTranslations.text,
|
sourceText: sourceEntries.headword,
|
||||||
targetText: targetTranslations.text,
|
targetText: targetTranslations.translation,
|
||||||
sourceGloss: term_glosses.text,
|
sourceGloss: sourceEntries.gloss,
|
||||||
})
|
})
|
||||||
.from(terms)
|
.from(sourceEntries)
|
||||||
.innerJoin(
|
|
||||||
sourceTranslations,
|
|
||||||
and(
|
|
||||||
eq(sourceTranslations.term_id, terms.id),
|
|
||||||
eq(sourceTranslations.language_code, sourceLanguage), // Filter here!
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
targetTranslations,
|
targetTranslations,
|
||||||
and(
|
and(
|
||||||
eq(targetTranslations.term_id, terms.id),
|
eq(targetTranslations.entry_id, sourceEntries.id),
|
||||||
eq(targetTranslations.language_code, targetLanguage), // Filter here!
|
eq(targetTranslations.target_language_code, targetLanguage),
|
||||||
),
|
eq(targetTranslations.difficulty, difficulty),
|
||||||
)
|
isNotNull(targetTranslations.translation),
|
||||||
.leftJoin(
|
|
||||||
term_glosses,
|
|
||||||
and(
|
|
||||||
eq(term_glosses.term_id, terms.id),
|
|
||||||
eq(term_glosses.language_code, sourceLanguage),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(terms.pos, pos),
|
eq(sourceEntries.language_code, sourceLanguage),
|
||||||
eq(targetTranslations.difficulty, difficulty),
|
eq(sourceEntries.pos, pos),
|
||||||
isNotNull(sourceTranslations.difficulty), // Good data quality check!
|
isNotNull(sourceEntries.difficulty),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set before
|
// TODO(post-mvp): ORDER BY RANDOM() sorts the entire filtered result set
|
||||||
// applying LIMIT, which is fine at current data volumes (low thousands of rows
|
// before applying LIMIT, which is fine at current data volumes but degrades
|
||||||
// after POS + difficulty filters) but degrades as the terms table grows. Once
|
// as the table grows. See original termModel.ts for optimisation options.
|
||||||
// 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()`)
|
.orderBy(sql`RANDOM()`)
|
||||||
.limit(rounds);
|
.limit(rounds);
|
||||||
|
|
||||||
|
|
@ -81,32 +66,33 @@ export const getGameTerms = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDistractors = async (
|
export const getDistractors = async (
|
||||||
excludeTermId: string,
|
excludeEntryId: string,
|
||||||
excludeText: string,
|
excludeText: string,
|
||||||
|
sourceLanguage: SupportedLanguageCode,
|
||||||
targetLanguage: SupportedLanguageCode,
|
targetLanguage: SupportedLanguageCode,
|
||||||
pos: SupportedPos,
|
pos: SupportedPos,
|
||||||
difficulty: DifficultyLevel,
|
difficulty: DifficultyLevel,
|
||||||
count: number,
|
count: number,
|
||||||
): Promise<string[]> => {
|
): Promise<string[]> => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ text: translations.text })
|
.select({ text: entry_translations.translation })
|
||||||
.from(terms)
|
.from(vocabulary_entries)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
translations,
|
entry_translations,
|
||||||
and(
|
and(
|
||||||
eq(translations.term_id, terms.id),
|
eq(entry_translations.entry_id, vocabulary_entries.id),
|
||||||
eq(translations.language_code, targetLanguage),
|
eq(entry_translations.target_language_code, targetLanguage),
|
||||||
|
eq(entry_translations.difficulty, difficulty),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(terms.pos, pos),
|
eq(vocabulary_entries.language_code, sourceLanguage),
|
||||||
eq(translations.difficulty, difficulty),
|
eq(vocabulary_entries.pos, pos),
|
||||||
ne(terms.id, excludeTermId),
|
ne(vocabulary_entries.id, excludeEntryId),
|
||||||
ne(translations.text, excludeText),
|
ne(entry_translations.translation, excludeText),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
// TODO(post-mvp): same ORDER BY RANDOM() concern as getGameTerms — see comment there.
|
|
||||||
.orderBy(sql`RANDOM()`)
|
.orderBy(sql`RANDOM()`)
|
||||||
.limit(count);
|
.limit(count);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue