refactoring data model
This commit is contained in:
parent
b16b5db3f7
commit
e80f291c41
3 changed files with 95 additions and 64 deletions
|
|
@ -6,7 +6,6 @@ import {
|
|||
varchar,
|
||||
unique,
|
||||
check,
|
||||
boolean,
|
||||
primaryKey,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
SUPPORTED_POS,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
CEFR_LEVELS,
|
||||
SUPPORTED_DECK_TYPES,
|
||||
} from "@glossa/shared";
|
||||
|
||||
export const terms = pgTable(
|
||||
|
|
@ -26,7 +26,6 @@ export const terms = pgTable(
|
|||
source: varchar({ length: 50 }), // 'omw', 'wiktionary', null for manual
|
||||
source_id: text(), // synset_id value for omw, wiktionary QID, etc.
|
||||
pos: varchar({ length: 20 }).notNull(),
|
||||
cefr_level: varchar({ length: 2 }),
|
||||
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
|
|
@ -34,10 +33,6 @@ export const terms = pgTable(
|
|||
"pos_check",
|
||||
sql`${table.pos} IN (${sql.raw(SUPPORTED_POS.map((p) => `'${p}'`).join(", "))})`,
|
||||
),
|
||||
check(
|
||||
"cefr_check",
|
||||
sql`${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((p) => `'${p}'`).join(", "))})`,
|
||||
),
|
||||
unique("unique_source_id").on(table.source, table.source_id),
|
||||
index("idx_terms_source_pos").on(table.source, table.pos),
|
||||
],
|
||||
|
|
@ -76,6 +71,7 @@ export const translations = pgTable(
|
|||
.references(() => terms.id, { onDelete: "cascade" }),
|
||||
language_code: varchar({ length: 10 }).notNull(),
|
||||
text: text().notNull(),
|
||||
cefr_level: varchar({ length: 2 }),
|
||||
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
|
|
@ -88,41 +84,14 @@ export const translations = pgTable(
|
|||
"language_code_check",
|
||||
sql`${table.language_code} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
|
||||
),
|
||||
index("idx_translations_lang").on(table.language_code, table.term_id),
|
||||
],
|
||||
);
|
||||
|
||||
export const language_pairs = pgTable(
|
||||
"language_pairs",
|
||||
{
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
source_language: varchar({ length: 10 }).notNull(),
|
||||
target_language: varchar({ length: 10 }).notNull(),
|
||||
label: text(),
|
||||
active: boolean().default(true).notNull(),
|
||||
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
unique("unique_source_target").on(
|
||||
table.source_language,
|
||||
table.target_language,
|
||||
),
|
||||
check(
|
||||
"source_language_check",
|
||||
sql`${table.source_language} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
|
||||
"cefr_check",
|
||||
sql`${table.cefr_level} IN (${sql.raw(CEFR_LEVELS.map((l) => `'${l}'`).join(", "))})`,
|
||||
),
|
||||
check(
|
||||
"target_language_check",
|
||||
sql`${table.target_language} IN (${sql.raw(SUPPORTED_LANGUAGE_CODES.map((l) => `'${l}'`).join(", "))})`,
|
||||
),
|
||||
check(
|
||||
"no_self_pair",
|
||||
sql`${table.source_language} != ${table.target_language}`,
|
||||
),
|
||||
index("idx_pairs_active").on(
|
||||
table.active,
|
||||
table.source_language,
|
||||
table.target_language,
|
||||
index("idx_translations_lang").on(
|
||||
table.language_code,
|
||||
table.cefr_level,
|
||||
table.term_id,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -149,7 +118,7 @@ export const decks = pgTable(
|
|||
description: text(),
|
||||
source_language: varchar({ length: 10 }).notNull(),
|
||||
validated_languages: varchar({ length: 10 }).array().notNull().default([]),
|
||||
is_public: boolean().default(false).notNull(),
|
||||
type: varchar({ length: 20 }).notNull(),
|
||||
created_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
|
|
@ -165,7 +134,12 @@ export const decks = pgTable(
|
|||
"validated_languages_excludes_source",
|
||||
sql`NOT (${table.source_language} = ANY(${table.validated_languages}))`,
|
||||
),
|
||||
check(
|
||||
"deck_type_check",
|
||||
sql`${table.type} IN (${sql.raw(SUPPORTED_DECK_TYPES.map((t) => `'${t}'`).join(", "))})`,
|
||||
),
|
||||
unique("unique_deck_name").on(table.name, table.source_language),
|
||||
index("idx_decks_type").on(table.type, table.source_language),
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -178,31 +152,37 @@ export const deck_terms = pgTable(
|
|||
term_id: uuid()
|
||||
.notNull()
|
||||
.references(() => terms.id, { onDelete: "cascade" }),
|
||||
added_at: timestamp({ withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(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] })],
|
||||
);
|
||||
|
||||
/*
|
||||
* INTENTIONAL DESIGN DECISIONS
|
||||
* INTENTIONAL DESIGN DECISIONS — see decisions.md for full reasoning
|
||||
*
|
||||
* surrogate id + synset_id (terms):
|
||||
* Both exist on purpose. synset_id is the natural WordNet key used for lookups
|
||||
* and re-imports. id is the stable internal FK target — if synset IDs change in
|
||||
* a future WordNet version, FK references don't need to cascade.
|
||||
*
|
||||
* display_name UNIQUE (users):
|
||||
* Unique usernames are a feature, not an oversight. One "Alex" per app.
|
||||
*
|
||||
* UNIQUE(term_id, language_code, text) (translations):
|
||||
* This does allow synonyms. "banco" and "orilla" are different text values and
|
||||
* both insert cleanly. The constraint only prevents exact duplicate rows.
|
||||
*
|
||||
* updated_at omitted:
|
||||
* A column with DEFAULT now() that is never written on updates is misleading.
|
||||
* Omitted until a trigger or ORM hook is in place to actually maintain it.
|
||||
*
|
||||
* FK indexes:
|
||||
* All FK columns are covered — either by explicit indexes, composite unique
|
||||
* indexes, or the composite PK on deck_terms. No sequential scans on joins.
|
||||
* source + source_id (terms): idempotency key per import pipeline
|
||||
* display_name UNIQUE (users): multiplayer requires distinguishable names
|
||||
* UNIQUE(term_id, language_code, text): allows synonyms, prevents exact duplicates
|
||||
* updated_at omitted: misleading without a trigger to maintain it
|
||||
* FK indexes: all FK columns covered, no sequential scans on joins
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,3 +5,5 @@ export const SUPPORTED_POS = ["noun", "verb"] as const;
|
|||
export const GAME_ROUNDS = ["3", "10"] as const;
|
||||
|
||||
export const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] as const;
|
||||
|
||||
export const SUPPORTED_DECK_TYPES = ["grammar", "media"] as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue