feat(db): drizzle schema, migrations, and seed
This commit is contained in:
19
packages/backend/src/db/client.ts
Normal file
19
packages/backend/src/db/client.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
export type Db = BetterSQLite3Database<typeof schema>;
|
||||
|
||||
export function createDb(dbPath?: string): { db: Db; sqlite: Database.Database } {
|
||||
const path = dbPath ?? process.env.DB_PATH ?? resolve(process.cwd(), '../../data/flashcard.db');
|
||||
if (path !== ':memory:') {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
}
|
||||
const sqlite = new Database(path);
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
const db = drizzle(sqlite, { schema });
|
||||
return { db, sqlite };
|
||||
}
|
||||
8
packages/backend/src/db/migrate.ts
Normal file
8
packages/backend/src/db/migrate.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
import { resolve } from 'node:path';
|
||||
import { createDb } from './client.js';
|
||||
|
||||
const { db, sqlite } = createDb();
|
||||
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
|
||||
sqlite.close();
|
||||
console.log('Migrations applied.');
|
||||
85
packages/backend/src/db/schema.ts
Normal file
85
packages/backend/src/db/schema.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { integer, sqliteTable, text, index } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const lessons = sqliteTable('lessons', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
parentId: integer('parent_id'),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
position: integer('position').notNull().default(0),
|
||||
bidirectional: integer('bidirectional', { mode: 'boolean' }).notNull().default(false),
|
||||
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export const cards = sqliteTable(
|
||||
'cards',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
|
||||
question: text('question').notNull(),
|
||||
answer: text('answer').notNull(),
|
||||
hint: text('hint'),
|
||||
position: integer('position').notNull().default(0),
|
||||
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
|
||||
updatedAt: integer('updated_at').notNull().default(sql`(unixepoch())`),
|
||||
},
|
||||
(t) => ({ lessonIdx: index('cards_lesson_idx').on(t.lessonId) })
|
||||
);
|
||||
|
||||
export const cardProgress = sqliteTable(
|
||||
'card_progress',
|
||||
{
|
||||
cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
|
||||
direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
|
||||
box: integer('box').notNull().default(1),
|
||||
correctCount: integer('correct_count').notNull().default(0),
|
||||
incorrectCount: integer('incorrect_count').notNull().default(0),
|
||||
lastShownAt: integer('last_shown_at'),
|
||||
nextDueAt: integer('next_due_at').notNull().default(0),
|
||||
},
|
||||
(t) => ({
|
||||
pk: index('card_progress_pk').on(t.cardId, t.direction),
|
||||
dueIdx: index('card_progress_due_idx').on(t.nextDueAt),
|
||||
})
|
||||
);
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
'sessions',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
|
||||
startedAt: integer('started_at').notNull().default(sql`(unixepoch())`),
|
||||
endedAt: integer('ended_at'),
|
||||
durationSeconds: integer('duration_seconds'),
|
||||
cardsShown: integer('cards_shown').notNull().default(0),
|
||||
cardsCorrect: integer('cards_correct').notNull().default(0),
|
||||
cardsIncorrect: integer('cards_incorrect').notNull().default(0),
|
||||
status: text('status', { enum: ['active', 'completed', 'abandoned'] }).notNull().default('active'),
|
||||
queueSnapshot: text('queue_snapshot'),
|
||||
},
|
||||
(t) => ({ statusIdx: index('sessions_status_idx').on(t.status) })
|
||||
);
|
||||
|
||||
export const attempts = sqliteTable(
|
||||
'attempts',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
sessionId: integer('session_id').notNull().references(() => sessions.id, { onDelete: 'cascade' }),
|
||||
cardId: integer('card_id').notNull().references(() => cards.id, { onDelete: 'cascade' }),
|
||||
direction: text('direction', { enum: ['forward', 'backward'] }).notNull(),
|
||||
shownAt: integer('shown_at').notNull().default(sql`(unixepoch())`),
|
||||
result: text('result', { enum: ['correct', 'incorrect'] }).notNull(),
|
||||
timeToAnswerMs: integer('time_to_answer_ms'),
|
||||
},
|
||||
(t) => ({
|
||||
sessionIdx: index('attempts_session_idx').on(t.sessionId),
|
||||
cardIdx: index('attempts_card_idx').on(t.cardId),
|
||||
})
|
||||
);
|
||||
|
||||
export type LessonRow = typeof lessons.$inferSelect;
|
||||
export type CardRow = typeof cards.$inferSelect;
|
||||
export type CardProgressRow = typeof cardProgress.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
export type AttemptRow = typeof attempts.$inferSelect;
|
||||
13
packages/backend/src/db/seed.ts
Normal file
13
packages/backend/src/db/seed.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createDb } from './client.js';
|
||||
import { cards, lessons } from './schema.js';
|
||||
|
||||
const { db, sqlite } = createDb();
|
||||
const [root] = db.insert(lessons).values({ name: 'Demo: Spaans', position: 0 }).returning().all();
|
||||
const [sub] = db.insert(lessons).values({ name: 'Begroetingen', parentId: root.id, position: 0 }).returning().all();
|
||||
db.insert(cards).values([
|
||||
{ lessonId: sub.id, question: 'Hallo', answer: 'Hola', position: 0 },
|
||||
{ lessonId: sub.id, question: 'Goedemorgen', answer: 'Buenos días', position: 1 },
|
||||
{ lessonId: sub.id, question: 'Tot ziens', answer: 'Adiós', position: 2 },
|
||||
]).run();
|
||||
sqlite.close();
|
||||
console.log('Seed inserted.');
|
||||
Reference in New Issue
Block a user