From 1584901c0a9aee3fce9c7132b48ec2e5556abb70 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 20:40:00 +0200 Subject: [PATCH] feat(db): drizzle schema, migrations, and seed --- packages/backend/drizzle.config.ts | 10 + .../backend/drizzle/0000_exotic_wrecker.sql | 66 +++ .../backend/drizzle/meta/0000_snapshot.json | 475 ++++++++++++++++++ packages/backend/drizzle/meta/_journal.json | 13 + packages/backend/src/db/client.ts | 19 + packages/backend/src/db/migrate.ts | 8 + packages/backend/src/db/schema.ts | 85 ++++ packages/backend/src/db/seed.ts | 13 + 8 files changed, 689 insertions(+) create mode 100644 packages/backend/drizzle.config.ts create mode 100644 packages/backend/drizzle/0000_exotic_wrecker.sql create mode 100644 packages/backend/drizzle/meta/0000_snapshot.json create mode 100644 packages/backend/drizzle/meta/_journal.json create mode 100644 packages/backend/src/db/client.ts create mode 100644 packages/backend/src/db/migrate.ts create mode 100644 packages/backend/src/db/schema.ts create mode 100644 packages/backend/src/db/seed.ts diff --git a/packages/backend/drizzle.config.ts b/packages/backend/drizzle.config.ts new file mode 100644 index 0000000..2bc7424 --- /dev/null +++ b/packages/backend/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/db/schema.ts', + out: './drizzle', + dialect: 'sqlite', + dbCredentials: { + url: process.env.DB_PATH ?? '../../data/flashcard.db', + }, +}); diff --git a/packages/backend/drizzle/0000_exotic_wrecker.sql b/packages/backend/drizzle/0000_exotic_wrecker.sql new file mode 100644 index 0000000..3305052 --- /dev/null +++ b/packages/backend/drizzle/0000_exotic_wrecker.sql @@ -0,0 +1,66 @@ +CREATE TABLE `attempts` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `session_id` integer NOT NULL, + `card_id` integer NOT NULL, + `direction` text NOT NULL, + `shown_at` integer DEFAULT (unixepoch()) NOT NULL, + `result` text NOT NULL, + `time_to_answer_ms` integer, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`card_id`) REFERENCES `cards`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `card_progress` ( + `card_id` integer NOT NULL, + `direction` text NOT NULL, + `box` integer DEFAULT 1 NOT NULL, + `correct_count` integer DEFAULT 0 NOT NULL, + `incorrect_count` integer DEFAULT 0 NOT NULL, + `last_shown_at` integer, + `next_due_at` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`card_id`) REFERENCES `cards`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `cards` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `lesson_id` integer NOT NULL, + `question` text NOT NULL, + `answer` text NOT NULL, + `hint` text, + `position` integer DEFAULT 0 NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL, + FOREIGN KEY (`lesson_id`) REFERENCES `lessons`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `lessons` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `parent_id` integer, + `name` text NOT NULL, + `description` text, + `position` integer DEFAULT 0 NOT NULL, + `bidirectional` integer DEFAULT false NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `lesson_id` integer NOT NULL, + `started_at` integer DEFAULT (unixepoch()) NOT NULL, + `ended_at` integer, + `duration_seconds` integer, + `cards_shown` integer DEFAULT 0 NOT NULL, + `cards_correct` integer DEFAULT 0 NOT NULL, + `cards_incorrect` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `queue_snapshot` text, + FOREIGN KEY (`lesson_id`) REFERENCES `lessons`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `attempts_session_idx` ON `attempts` (`session_id`);--> statement-breakpoint +CREATE INDEX `attempts_card_idx` ON `attempts` (`card_id`);--> statement-breakpoint +CREATE INDEX `card_progress_pk` ON `card_progress` (`card_id`,`direction`);--> statement-breakpoint +CREATE INDEX `card_progress_due_idx` ON `card_progress` (`next_due_at`);--> statement-breakpoint +CREATE INDEX `cards_lesson_idx` ON `cards` (`lesson_id`);--> statement-breakpoint +CREATE INDEX `sessions_status_idx` ON `sessions` (`status`); \ No newline at end of file diff --git a/packages/backend/drizzle/meta/0000_snapshot.json b/packages/backend/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..396705b --- /dev/null +++ b/packages/backend/drizzle/meta/0000_snapshot.json @@ -0,0 +1,475 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "00509e45-a6d9-417d-b3c0-a7e936e7001f", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "attempts": { + "name": "attempts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "session_id": { + "name": "session_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shown_at": { + "name": "shown_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "time_to_answer_ms": { + "name": "time_to_answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "attempts_session_idx": { + "name": "attempts_session_idx", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "attempts_card_idx": { + "name": "attempts_card_idx", + "columns": [ + "card_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "attempts_session_id_sessions_id_fk": { + "name": "attempts_session_id_sessions_id_fk", + "tableFrom": "attempts", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "attempts_card_id_cards_id_fk": { + "name": "attempts_card_id_cards_id_fk", + "tableFrom": "attempts", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "card_progress": { + "name": "card_progress", + "columns": { + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "box": { + "name": "box", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "correct_count": { + "name": "correct_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "incorrect_count": { + "name": "incorrect_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_shown_at": { + "name": "last_shown_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_due_at": { + "name": "next_due_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "card_progress_pk": { + "name": "card_progress_pk", + "columns": [ + "card_id", + "direction" + ], + "isUnique": false + }, + "card_progress_due_idx": { + "name": "card_progress_due_idx", + "columns": [ + "next_due_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "card_progress_card_id_cards_id_fk": { + "name": "card_progress_card_id_cards_id_fk", + "tableFrom": "card_progress", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "cards": { + "name": "cards", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hint": { + "name": "hint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "cards_lesson_idx": { + "name": "cards_lesson_idx", + "columns": [ + "lesson_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cards_lesson_id_lessons_id_fk": { + "name": "cards_lesson_id_lessons_id_fk", + "tableFrom": "cards", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "lessons": { + "name": "lessons", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "bidirectional": { + "name": "bidirectional", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cards_shown": { + "name": "cards_shown", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cards_correct": { + "name": "cards_correct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cards_incorrect": { + "name": "cards_incorrect", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "queue_snapshot": { + "name": "queue_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sessions_status_idx": { + "name": "sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_lesson_id_lessons_id_fk": { + "name": "sessions_lesson_id_lessons_id_fk", + "tableFrom": "sessions", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/backend/drizzle/meta/_journal.json b/packages/backend/drizzle/meta/_journal.json new file mode 100644 index 0000000..8d41a15 --- /dev/null +++ b/packages/backend/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1779302371078, + "tag": "0000_exotic_wrecker", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/backend/src/db/client.ts b/packages/backend/src/db/client.ts new file mode 100644 index 0000000..5d9696a --- /dev/null +++ b/packages/backend/src/db/client.ts @@ -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; + +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 }; +} diff --git a/packages/backend/src/db/migrate.ts b/packages/backend/src/db/migrate.ts new file mode 100644 index 0000000..327b92e --- /dev/null +++ b/packages/backend/src/db/migrate.ts @@ -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.'); diff --git a/packages/backend/src/db/schema.ts b/packages/backend/src/db/schema.ts new file mode 100644 index 0000000..b1e68f1 --- /dev/null +++ b/packages/backend/src/db/schema.ts @@ -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; diff --git a/packages/backend/src/db/seed.ts b/packages/backend/src/db/seed.ts new file mode 100644 index 0000000..db2a9bd --- /dev/null +++ b/packages/backend/src/db/seed.ts @@ -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.');