diff --git a/packages/db/src/db/schema.ts b/packages/db/src/db/schema.ts index 4eb8473..e07e231 100644 --- a/packages/db/src/db/schema.ts +++ b/packages/db/src/db/schema.ts @@ -21,6 +21,7 @@ import { CEFR_LEVELS, DIFFICULTY_LEVELS, LOBBY_STATUSES, + NOUN_GENDERS, } from "@lila/shared"; // ── Vocabulary ──────────────────────────────────────────────────────────────── @@ -112,6 +113,95 @@ export const entry_translations = pgTable( ], ); +export const words = pgTable( + "words", + { + id: uuid().primaryKey().defaultRandom(), + headword: text().notNull(), + language_code: varchar({ length: 10 }).notNull(), + pos: varchar({ length: 20 }).notNull(), + created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + unique("unique_word_per_language_and_pos").on( + table.headword, + table.language_code, + table.pos, + ), + check( + "words_language_code_check", + sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((code) => `'${code}'`).join(", "))})`, + ), + check( + "words_pos_check", + sql`${table.pos} IN (${sql.raw(SUPPORTED_POS.map((pos) => `'${pos}'`).join(", "))})`, + ), + index("idx_language_code_pos").on(table.language_code, table.pos), + ], +); + +export const senses = pgTable( + "senses", + { + id: uuid().primaryKey().defaultRandom(), + word_id: uuid() + .notNull() + .references(() => words.id, { onDelete: "cascade" }), + sense_index: smallint().default(0).notNull(), + difficulty: varchar({ length: 20 }).notNull(), + definitions: text().array().notNull().default([]), + examples: text().array().notNull().default([]), + created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + unique("unique_sense_per_word").on(table.word_id, table.sense_index), + check( + "senses_difficulty_check", + sql`${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((lvl) => `'${lvl}'`).join(", "))})`, + ), + index("idx_word_sense_difficulty").on(table.word_id, table.difficulty), + ], +); + +export const translations = pgTable( + "translations", + { + id: uuid().primaryKey().defaultRandom(), + sense_id: uuid() + .notNull() + .references(() => senses.id, { onDelete: "cascade" }), + target_language_code: varchar({ length: 10 }).notNull(), + translation: text().notNull(), + gender: varchar({ length: 20 }), + difficulty: varchar({ length: 20 }).notNull(), + created_at: timestamp({ withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + unique("unique_translation_per_sense").on( + table.sense_id, + table.target_language_code, + table.translation, + ), + check( + "translations_language_code_check", + sql`${table.target_language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((code) => `'${code}'`).join(", "))})`, + ), + check( + "translations_difficulty_check", + sql`${table.difficulty} IN (${sql.raw(DIFFICULTY_LEVELS.map((lvl) => `'${lvl}'`).join(", "))})`, + ), + check( + "translations_gender_check", + sql`${table.gender} IS NULL OR ${table.gender} IN (${sql.raw(NOUN_GENDERS.map((gender) => `'${gender}'`).join(", "))})`, + ), + index("idx_translations_sense_language_difficulty").on( + table.sense_id, + table.target_language_code, + table.difficulty, + ), + ], +); + // ── Auth (managed by Better Auth) ───────────────────────────────────────────── export const user = pgTable("user", { @@ -243,6 +333,22 @@ export const entryTranslationRelations = relations( }), ); +export const wordRelations = relations(words, ({ many }) => ({ + senses: many(senses), +})); + +export const senseRelations = relations(senses, ({ one, many }) => ({ + word: one(words, { fields: [senses.word_id], references: [words.id] }), + translations: many(translations), +})); + +export const translationRelations = relations(translations, ({ one }) => ({ + sense: one(senses, { + fields: [translations.sense_id], + references: [senses.id], + }), +})); + export const userRelations = relations(user, ({ many }) => ({ sessions: many(session), accounts: many(account),