diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts new file mode 100644 index 0000000..795ea1a --- /dev/null +++ b/src/lib/server/db/index.ts @@ -0,0 +1,20 @@ +import Database from 'better-sqlite3'; +import { runMigrations } from './migrate'; + +let instance: Database.Database | null = null; + +export function getDb(path = process.env.DATABASE_PATH ?? './data/kochwas.db'): Database.Database { + if (instance) return instance; + instance = new Database(path); + instance.pragma('journal_mode = WAL'); + instance.pragma('foreign_keys = ON'); + runMigrations(instance); + return instance; +} + +export function openInMemoryForTest(): Database.Database { + const db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + return db; +} diff --git a/src/lib/server/db/migrate.ts b/src/lib/server/db/migrate.ts new file mode 100644 index 0000000..1ee546a --- /dev/null +++ b/src/lib/server/db/migrate.ts @@ -0,0 +1,31 @@ +import type Database from 'better-sqlite3'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function runMigrations(db: Database.Database): void { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migration ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + );`); + + const here = dirname(fileURLToPath(import.meta.url)); + const dir = join(here, 'migrations'); + const files = readdirSync(dir) + .filter((f) => f.endsWith('.sql')) + .sort(); + + const applied = new Set( + (db.prepare('SELECT name FROM schema_migration').all() as { name: string }[]).map((r) => r.name) + ); + + for (const file of files) { + if (applied.has(file)) continue; + const sql = readFileSync(join(dir, file), 'utf8'); + const tx = db.transaction(() => { + db.exec(sql); + db.prepare('INSERT INTO schema_migration(name) VALUES (?)').run(file); + }); + tx(); + } +} diff --git a/src/lib/server/db/migrations/001_init.sql b/src/lib/server/db/migrations/001_init.sql new file mode 100644 index 0000000..ccbcbc9 --- /dev/null +++ b/src/lib/server/db/migrations/001_init.sql @@ -0,0 +1,140 @@ +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS profile ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + avatar_emoji TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS recipe ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + source_url TEXT UNIQUE, + source_domain TEXT, + image_path TEXT, + servings_default INTEGER, + servings_unit TEXT, + prep_time_min INTEGER, + cook_time_min INTEGER, + total_time_min INTEGER, + cuisine TEXT, + category TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS ingredient ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + quantity REAL, + unit TEXT, + name TEXT NOT NULL, + note TEXT, + raw_text TEXT +); +CREATE INDEX IF NOT EXISTS ix_ingredient_recipe ON ingredient(recipe_id, position); + +CREATE TABLE IF NOT EXISTS step ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + position INTEGER NOT NULL, + text TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS ix_step_recipe ON step(recipe_id, position); + +CREATE TABLE IF NOT EXISTS tag ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS recipe_tag ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE, + PRIMARY KEY (recipe_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS rating ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + stars INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (recipe_id, profile_id) +); + +CREATE TABLE IF NOT EXISTS comment ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + text TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS favorite ( + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (recipe_id, profile_id) +); + +CREATE TABLE IF NOT EXISTS cooking_log ( + id INTEGER PRIMARY KEY, + recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE, + profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + cooked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS ix_cooking_log_recipe ON cooking_log(recipe_id, cooked_at); + +CREATE TABLE IF NOT EXISTS allowed_domain ( + id INTEGER PRIMARY KEY, + domain TEXT UNIQUE NOT NULL, + display_name TEXT, + added_by_profile_id INTEGER REFERENCES profile(id), + added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5( + title, description, ingredients_concat, tags_concat, + content='', tokenize='unicode61 remove_diacritics 2' +); + +CREATE TRIGGER IF NOT EXISTS trg_recipe_ai AFTER INSERT ON recipe BEGIN + INSERT INTO recipe_fts(rowid, title, description, ingredients_concat, tags_concat) + VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''), '', ''); +END; + +CREATE TRIGGER IF NOT EXISTS trg_recipe_ad AFTER DELETE ON recipe BEGIN + INSERT INTO recipe_fts(recipe_fts, rowid, title, description, ingredients_concat, tags_concat) + VALUES ('delete', OLD.id, OLD.title, COALESCE(OLD.description, ''), '', ''); +END; + +CREATE TRIGGER IF NOT EXISTS trg_recipe_au AFTER UPDATE ON recipe BEGIN + INSERT INTO recipe_fts(recipe_fts, rowid, title, description, ingredients_concat, tags_concat) + VALUES ('delete', OLD.id, OLD.title, COALESCE(OLD.description, ''), '', ''); + INSERT INTO recipe_fts(rowid, title, description, ingredients_concat, tags_concat) + VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''), + COALESCE((SELECT group_concat(name, ' ') FROM ingredient WHERE recipe_id = NEW.id), ''), + COALESCE((SELECT group_concat(t.name, ' ') FROM tag t JOIN recipe_tag rt ON rt.tag_id = t.id WHERE rt.recipe_id = NEW.id), '') + ); +END; + +CREATE TRIGGER IF NOT EXISTS trg_ingredient_ai AFTER INSERT ON ingredient BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.recipe_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_ingredient_ad AFTER DELETE ON ingredient BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.recipe_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_recipe_tag_ai AFTER INSERT ON recipe_tag BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.recipe_id; +END; +CREATE TRIGGER IF NOT EXISTS trg_recipe_tag_ad AFTER DELETE ON recipe_tag BEGIN + UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.recipe_id; +END; + +CREATE TABLE IF NOT EXISTS schema_migration ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/tests/integration/db.test.ts b/tests/integration/db.test.ts new file mode 100644 index 0000000..2bb5f37 --- /dev/null +++ b/tests/integration/db.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; +import { openInMemoryForTest } from '../../src/lib/server/db'; +import { runMigrations } from '../../src/lib/server/db/migrate'; + +describe('db migrations', () => { + it('creates all expected tables', () => { + const db = openInMemoryForTest(); + const tables = ( + db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all() as { + name: string; + }[] + ).map((r) => r.name); + for (const t of [ + 'profile', + 'recipe', + 'ingredient', + 'step', + 'tag', + 'recipe_tag', + 'rating', + 'comment', + 'favorite', + 'cooking_log', + 'allowed_domain', + 'schema_migration' + ]) { + expect(tables).toContain(t); + } + }); + + it('is idempotent', () => { + const db = openInMemoryForTest(); + runMigrations(db); + const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number }; + expect(migs.c).toBe(1); + }); + + it('cascades recipe delete to ingredients and steps', () => { + const db = openInMemoryForTest(); + const row = db + .prepare('INSERT INTO recipe(title) VALUES (?) RETURNING id') + .get('Test') as { id: number }; + db.prepare('INSERT INTO ingredient(recipe_id, position, name) VALUES (?, ?, ?)').run( + row.id, + 1, + 'Salz' + ); + db.prepare('INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)').run( + row.id, + 1, + 'Kochen' + ); + db.prepare('DELETE FROM recipe WHERE id = ?').run(row.id); + const ings = db.prepare('SELECT COUNT(*) AS c FROM ingredient').get() as { c: number }; + const steps = db.prepare('SELECT COUNT(*) AS c FROM step').get() as { c: number }; + expect(ings.c).toBe(0); + expect(steps.c).toBe(0); + }); + + it('FTS5 index finds recipes by title', () => { + const db = openInMemoryForTest(); + db.prepare('INSERT INTO recipe(title, description) VALUES (?, ?)').run( + 'Spaghetti Carbonara', + 'Italienisches Pasta-Klassiker' + ); + const hits = db + .prepare("SELECT rowid FROM recipe_fts WHERE recipe_fts MATCH 'carbonara'") + .all(); + expect(hits.length).toBe(1); + }); + + it('enforces rating stars 1..5', () => { + const db = openInMemoryForTest(); + db.prepare('INSERT INTO profile(name) VALUES (?)').run('Hendrik'); + const r = db + .prepare('INSERT INTO recipe(title) VALUES (?) RETURNING id') + .get('Test') as { id: number }; + db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 5)').run(r.id); + expect(() => + db.prepare('INSERT INTO rating(recipe_id, profile_id, stars) VALUES (?, 1, 6)').run(r.id) + ).toThrow(); + }); +});