feat(db): add SQLite schema, FTS5, migration runner
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
src/lib/server/db/index.ts
Normal file
20
src/lib/server/db/index.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
31
src/lib/server/db/migrate.ts
Normal file
31
src/lib/server/db/migrate.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/lib/server/db/migrations/001_init.sql
Normal file
140
src/lib/server/db/migrations/001_init.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
83
tests/integration/db.test.ts
Normal file
83
tests/integration/db.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user