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,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',
},
});

View File

@@ -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`);

View File

@@ -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": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1779302371078,
"tag": "0000_exotic_wrecker",
"breakpoints": true
}
]
}

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