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:
2026-04-17 15:04:59 +02:00
parent 2f3248c9a3
commit e90a37ff5e
4 changed files with 274 additions and 0 deletions

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

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

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