feat(db): drizzle schema, migrations, and seed

This commit is contained in:
2026-05-20 20:40:00 +02:00
parent d13af79940
commit 1584901c0a
8 changed files with 689 additions and 0 deletions

View 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 };
}

View 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.');

View 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;

View 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.');