38 KiB
Kochwas — Phase 1: Foundations
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up a SvelteKit + TypeScript project with SQLite/FTS5 schema, a running /api/health endpoint, and unit-tested pure-function modules (ingredient-parser, json-ld-extractor, scaler, iso8601-duration) — the building blocks everything else in Kochwas will rely on.
Architecture: SvelteKit (Node adapter) + TypeScript strict. better-sqlite3 as synchronous DB driver. Migration runner driven by ordered SQL files. Pure-function modules in src/lib/server/ that are exercised via Vitest. SearXNG pulled as Docker container but not integrated yet. Production Docker image deferred to Phase 6 — dev uses npm run dev.
Tech Stack: Node 22+, SvelteKit 2, TypeScript 5, better-sqlite3, Vitest 2, Zod (for JSON-LD shape validation), Docker Compose (SearXNG), pnpm or npm (npm is assumed below).
Reference spec: docs/superpowers/specs/2026-04-17-kochwas-design.md
File Structure (created in this phase)
package.json # deps, scripts
tsconfig.json # strict mode
svelte.config.js # adapter-node
vite.config.ts # Vitest config
.prettierrc # formatting
.eslintrc.cjs # linting
.env.example # documented env vars
.gitignore # node_modules, data, .svelte-kit
docker-compose.yml # SearXNG for dev
searxng/settings.yml # SearXNG config
src/app.d.ts # SvelteKit app type
src/app.html # base HTML shell
src/routes/+layout.svelte # minimal layout
src/routes/+page.svelte # placeholder homepage
src/routes/api/health/+server.ts # health endpoint
src/lib/server/db/index.ts # connection helper
src/lib/server/db/migrate.ts # migration runner
src/lib/server/db/migrations/001_init.sql # core schema + FTS5 triggers
src/lib/server/parsers/iso8601-duration.ts # ISO8601 → minutes
src/lib/server/parsers/ingredient.ts # "200 g Mehl" → structured
src/lib/server/parsers/json-ld-recipe.ts # HTML → Recipe shape
src/lib/server/recipes/scaler.ts # scale recipe by factor
src/lib/types.ts # shared types (Recipe, Ingredient, …)
tests/fixtures/chefkoch-carbonara.html # real HTML fixture
tests/fixtures/emmi-zucchinipuffer.html # real HTML fixture
tests/unit/iso8601-duration.test.ts
tests/unit/ingredient.test.ts
tests/unit/scaler.test.ts
tests/unit/json-ld-recipe.test.ts
tests/integration/db.test.ts
Task 1: Scaffold SvelteKit project
Files:
-
Create:
package.json,svelte.config.js,tsconfig.json,vite.config.ts,src/app.d.ts,src/app.html,src/routes/+layout.svelte,src/routes/+page.svelte,.gitignore,.prettierrc,.eslintrc.cjs -
Step 1: Create package.json
{
"name": "kochwas",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.8.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.9.0",
"eslint": "^9.14.0",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"svelte": "^5.1.0",
"svelte-check": "^4.0.5",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
},
"dependencies": {
"better-sqlite3": "^11.5.0",
"linkedom": "^0.18.5",
"zod": "^3.23.8"
}
}
- Step 2: Create tsconfig.json
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
- Step 3: Create svelte.config.js
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
};
- Step 4: Create vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['tests/**/*.test.ts'],
globals: false,
environment: 'node'
}
});
- Step 5: Create .gitignore, .prettierrc, src/app.html, src/app.d.ts, src/routes/+layout.svelte, src/routes/+page.svelte
.gitignore:
node_modules/
.svelte-kit/
build/
data/
.env
.env.local
*.log
.prettierrc:
{
"useTabs": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
src/app.html:
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#2b6a3d" />
<title>Kochwas</title>
%sveltekit.head%
</head>
<body>
<div id="app">%sveltekit.body%</div>
</body>
</html>
src/app.d.ts:
declare global {
namespace App {}
}
export {};
src/routes/+layout.svelte:
<slot />
src/routes/+page.svelte:
<h1>Kochwas</h1>
<p>Coming soon.</p>
- Step 6: Install & verify
Run:
npm install
npx svelte-kit sync
npm run check
Expected: no type errors.
- Step 7: Commit
git add -A
git commit -m "feat(scaffold): init SvelteKit + TypeScript project"
Task 2: Set up Vitest with a smoke test
Files:
-
Create:
tests/unit/smoke.test.ts -
Step 1: Write smoke test
// tests/unit/smoke.test.ts
import { describe, it, expect } from 'vitest';
describe('smoke', () => {
it('runs', () => {
expect(1 + 1).toBe(2);
});
});
- Step 2: Run
npm test
Expected: 1 test passes.
- Step 3: Commit
git add tests/
git commit -m "test(infra): add vitest smoke test"
Task 3: ISO8601 duration parser
Files:
- Create:
src/lib/server/parsers/iso8601-duration.ts - Test:
tests/unit/iso8601-duration.test.ts
Purpose: JSON-LD recipes use ISO8601 durations like PT1H30M. We need minutes as an integer.
- Step 1: Write failing tests
// tests/unit/iso8601-duration.test.ts
import { describe, it, expect } from 'vitest';
import { parseIso8601Duration } from '../../src/lib/server/parsers/iso8601-duration';
describe('parseIso8601Duration', () => {
it.each([
['PT30M', 30],
['PT1H', 60],
['PT1H30M', 90],
['PT2H15M', 135],
['PT0M', 0],
['P1DT2H', 26 * 60],
['PT90M', 90]
])('parses %s to %i minutes', (input, expected) => {
expect(parseIso8601Duration(input)).toBe(expected);
});
it.each([[''], [null], [undefined], ['garbage'], ['30 min'], ['PT']])(
'returns null for invalid input %j',
(input) => {
expect(parseIso8601Duration(input as string)).toBeNull();
}
);
});
- Step 2: Run — should fail
npm test -- iso8601-duration
Expected: fails with "parseIso8601Duration is not a function" or similar.
- Step 3: Implement
// src/lib/server/parsers/iso8601-duration.ts
const PATTERN = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
export function parseIso8601Duration(input: string | null | undefined): number | null {
if (typeof input !== 'string' || input.length === 0) return null;
const m = PATTERN.exec(input);
if (!m) return null;
const [, d, h, min] = m;
if (!d && !h && !min && !input.includes('S')) {
// Must have at least one component to count; reject bare "PT"/"P"
if (input === 'P' || input === 'PT') return null;
}
const days = d ? parseInt(d, 10) : 0;
const hours = h ? parseInt(h, 10) : 0;
const mins = min ? parseInt(min, 10) : 0;
return days * 24 * 60 + hours * 60 + mins;
}
- Step 4: Run — should pass
npm test -- iso8601-duration
Expected: all tests pass.
- Step 5: Commit
git add src/lib/server/parsers/iso8601-duration.ts tests/unit/iso8601-duration.test.ts
git commit -m "feat(parser): add ISO8601 duration parser"
Task 4: Shared types
Files:
- Create:
src/lib/types.ts
Purpose: Central type definitions shared between server and client.
- Step 1: Write types
// src/lib/types.ts
export type Ingredient = {
position: number;
quantity: number | null;
unit: string | null;
name: string;
note: string | null;
raw_text: string;
};
export type Step = {
position: number;
text: string;
};
export type Recipe = {
id: number | null; // null for preview (not saved yet)
title: string;
description: string | null;
source_url: string | null;
source_domain: string | null;
image_path: string | null;
servings_default: number | null;
servings_unit: string | null;
prep_time_min: number | null;
cook_time_min: number | null;
total_time_min: number | null;
cuisine: string | null;
category: string | null;
ingredients: Ingredient[];
steps: Step[];
tags: string[];
};
export type Profile = {
id: number;
name: string;
avatar_emoji: string | null;
};
export type AllowedDomain = {
id: number;
domain: string;
display_name: string | null;
};
- Step 2: Commit
git add src/lib/types.ts
git commit -m "feat(types): add shared type definitions"
Task 5: Ingredient parser
Files:
- Create:
src/lib/server/parsers/ingredient.ts - Test:
tests/unit/ingredient.test.ts
Purpose: Convert free-text lines like "200 g Mehl" to a structured { quantity, unit, name, note, raw_text }.
- Step 1: Write failing tests
// tests/unit/ingredient.test.ts
import { describe, it, expect } from 'vitest';
import { parseIngredient } from '../../src/lib/server/parsers/ingredient';
describe('parseIngredient', () => {
it.each([
['200 g Mehl', { quantity: 200, unit: 'g', name: 'Mehl' }],
['1 kg Kartoffeln', { quantity: 1, unit: 'kg', name: 'Kartoffeln' }],
['500 ml Milch', { quantity: 500, unit: 'ml', name: 'Milch' }],
['1 TL Salz', { quantity: 1, unit: 'TL', name: 'Salz' }],
['2 EL Olivenöl', { quantity: 2, unit: 'EL', name: 'Olivenöl' }],
['3 Eier', { quantity: 3, unit: null, name: 'Eier' }],
['1/2 Zitrone', { quantity: 0.5, unit: null, name: 'Zitrone' }],
['1,5 l Wasser', { quantity: 1.5, unit: 'l', name: 'Wasser' }]
])('parses %s', (input, expected) => {
const parsed = parseIngredient(input);
expect(parsed.quantity).toBe(expected.quantity);
expect(parsed.unit).toBe(expected.unit);
expect(parsed.name).toBe(expected.name);
expect(parsed.raw_text).toBe(input);
});
it('handles notes', () => {
const p = parseIngredient('200 g Mehl (Type 550)');
expect(p.quantity).toBe(200);
expect(p.name).toBe('Mehl');
expect(p.note).toBe('Type 550');
});
it('falls back to raw_text when unparsable', () => {
const p = parseIngredient('etwas frischer Pfeffer');
expect(p.quantity).toBeNull();
expect(p.unit).toBeNull();
expect(p.name).toBe('etwas frischer Pfeffer');
expect(p.raw_text).toBe('etwas frischer Pfeffer');
});
it('handles ranges by taking the lower bound', () => {
const p = parseIngredient('2-3 Tomaten');
expect(p.quantity).toBe(2);
expect(p.name).toBe('Tomaten');
});
});
-
Step 2: Run — should fail
-
Step 3: Implement
// src/lib/server/parsers/ingredient.ts
import type { Ingredient } from '$lib/types';
const UNITS = new Set([
'g', 'kg', 'ml', 'l', 'cl', 'dl',
'TL', 'EL', 'Prise', 'Pck.', 'Pkg', 'Becher', 'Stk', 'Stück', 'Bund', 'Tasse', 'Dose'
]);
const FRACTION_MAP: Record<string, number> = {
'1/2': 0.5, '1/3': 1 / 3, '2/3': 2 / 3, '1/4': 0.25, '3/4': 0.75
};
function parseQuantity(raw: string): number | null {
const trimmed = raw.trim();
if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed];
// Range like "2-3" → take lower bound
const rangeMatch = /^(\d+[.,]?\d*)\s*[-–]\s*\d+[.,]?\d*$/.exec(trimmed);
if (rangeMatch) {
return parseFloat(rangeMatch[1].replace(',', '.'));
}
const num = parseFloat(trimmed.replace(',', '.'));
return Number.isFinite(num) ? num : null;
}
export function parseIngredient(raw: string, position = 0): Ingredient {
const rawText = raw.trim();
// Strip note in parentheses
let working = rawText;
let note: string | null = null;
const noteMatch = /\(([^)]+)\)/.exec(working);
if (noteMatch) {
note = noteMatch[1].trim();
working = (working.slice(0, noteMatch.index) + working.slice(noteMatch.index + noteMatch[0].length)).trim();
}
// Match: [quantity] [unit] <name>
// Quantity can be a number, fraction, or range
const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
const qtyMatch = qtyPattern.exec(working);
if (!qtyMatch) {
return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
}
const quantity = parseQuantity(qtyMatch[1]);
let rest = qtyMatch[2].trim();
let unit: string | null = null;
// Check if first token of rest is a unit
const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(rest);
if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) {
unit = firstTokenMatch[1];
rest = firstTokenMatch[2].trim();
}
return { position, quantity, unit, name: rest, note, raw_text: rawText };
}
-
Step 4: Run — should pass
-
Step 5: Commit
git add src/lib/server/parsers/ingredient.ts tests/unit/ingredient.test.ts
git commit -m "feat(parser): add ingredient parser"
Task 6: Scaler
Files:
- Create:
src/lib/server/recipes/scaler.ts - Test:
tests/unit/scaler.test.ts
Purpose: Scale ingredient quantities by a factor, leaving unparsable ingredients unchanged.
- Step 1: Write failing tests
// tests/unit/scaler.test.ts
import { describe, it, expect } from 'vitest';
import { scaleIngredients, roundQuantity } from '../../src/lib/server/recipes/scaler';
import type { Ingredient } from '../../src/lib/types';
const mk = (q: number | null, unit: string | null, name: string): Ingredient => ({
position: 0, quantity: q, unit, name, note: null, raw_text: ''
});
describe('roundQuantity', () => {
it.each([
[0.333333, 0.33],
[12.345, 12],
[1.55, 1.6],
[100.49, 100],
[0.5, 0.5]
])('rounds %f to %f', (input, expected) => {
expect(roundQuantity(input)).toBe(expected);
});
});
describe('scaleIngredients', () => {
it('scales parsed quantities', () => {
const scaled = scaleIngredients([mk(200, 'g', 'Mehl'), mk(3, null, 'Eier')], 2);
expect(scaled[0].quantity).toBe(400);
expect(scaled[1].quantity).toBe(6);
});
it('leaves null quantities alone', () => {
const scaled = scaleIngredients([mk(null, null, 'etwas Salz')], 2);
expect(scaled[0].quantity).toBeNull();
expect(scaled[0].name).toBe('etwas Salz');
});
it('rounds sensibly', () => {
const scaled = scaleIngredients([mk(100, 'g', 'Butter')], 1 / 3);
expect(scaled[0].quantity).toBe(33);
});
});
-
Step 2: Run — should fail
-
Step 3: Implement
// src/lib/server/recipes/scaler.ts
import type { Ingredient } from '$lib/types';
export function roundQuantity(q: number): number {
if (q === 0) return 0;
if (q < 1) return Math.round(q * 100) / 100; // 2 decimals
if (q < 10) return Math.round(q * 10) / 10; // 1 decimal
return Math.round(q); // integer
}
export function scaleIngredients(ings: Ingredient[], factor: number): Ingredient[] {
if (factor <= 0) throw new Error('factor must be positive');
return ings.map((i) => ({
...i,
quantity: i.quantity === null ? null : roundQuantity(i.quantity * factor)
}));
}
-
Step 4: Run — should pass
-
Step 5: Commit
git add src/lib/server/recipes/scaler.ts tests/unit/scaler.test.ts
git commit -m "feat(scaler): add ingredient scaling with sensible rounding"
Task 7: JSON-LD recipe extractor
Files:
- Create:
src/lib/server/parsers/json-ld-recipe.ts - Test:
tests/unit/json-ld-recipe.test.ts - Fixtures:
tests/fixtures/chefkoch-carbonara.html,tests/fixtures/emmi-zucchinipuffer.html
Purpose: Given a full HTML page, find the schema.org/Recipe JSON-LD block and return a normalized Recipe object (without id/image_path — those are filled by the importer in Phase 2).
- Step 1: Fetch real HTML fixtures
Run locally (curl or browser → Save As):
curl -sL -A "Mozilla/5.0" "https://www.chefkoch.de/rezepte/863891191419032/Spaghetti-Carbonara-the-real-one.html" -o tests/fixtures/chefkoch-carbonara.html
curl -sL -A "Mozilla/5.0" "https://emmikochteinfach.de/zucchinipuffer-einfach-und-schnell/" -o tests/fixtures/emmi-zucchinipuffer.html
If any fetch fails, note it and commit the others. The goal is at least one working fixture per format family.
- Step 2: Write failing tests
// tests/unit/json-ld-recipe.test.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe';
function load(name: string): string {
return readFileSync(join(__dirname, '../fixtures', name), 'utf8');
}
describe('extractRecipeFromHtml', () => {
it('extracts a recipe from Chefkoch HTML', () => {
const html = load('chefkoch-carbonara.html');
const r = extractRecipeFromHtml(html);
expect(r).not.toBeNull();
expect(r!.title.toLowerCase()).toContain('carbonara');
expect(r!.ingredients.length).toBeGreaterThan(2);
expect(r!.steps.length).toBeGreaterThan(0);
});
it('extracts a recipe from Emmi kocht einfach HTML', () => {
const html = load('emmi-zucchinipuffer.html');
const r = extractRecipeFromHtml(html);
expect(r).not.toBeNull();
expect(r!.title.toLowerCase()).toContain('zucchini');
expect(r!.ingredients.length).toBeGreaterThan(0);
});
it('returns null when no Recipe JSON-LD present', () => {
const html = '<html><body><p>no recipe</p></body></html>';
expect(extractRecipeFromHtml(html)).toBeNull();
});
});
-
Step 3: Run — should fail
-
Step 4: Implement
// src/lib/server/parsers/json-ld-recipe.ts
import { parseHTML } from 'linkedom';
import { parseIso8601Duration } from './iso8601-duration';
import { parseIngredient } from './ingredient';
import type { Recipe, Step } from '$lib/types';
type JsonLdNode = Record<string, unknown>;
function unwrapGraph(node: unknown): JsonLdNode[] {
if (Array.isArray(node)) return node.flatMap(unwrapGraph);
if (node && typeof node === 'object') {
const obj = node as JsonLdNode;
if (obj['@graph']) return unwrapGraph(obj['@graph']);
return [obj];
}
return [];
}
function isRecipeType(t: unknown): boolean {
if (typeof t === 'string') return t === 'Recipe' || t.endsWith('/Recipe');
if (Array.isArray(t)) return t.some(isRecipeType);
return false;
}
function toText(v: unknown): string | null {
if (typeof v === 'string') return v.trim() || null;
if (Array.isArray(v) && v.length > 0) return toText(v[0]);
if (v && typeof v === 'object') {
const o = v as JsonLdNode;
if (typeof o.name === 'string') return o.name.trim();
if (typeof o.text === 'string') return o.text.trim();
}
return null;
}
function toImagePath(v: unknown): string | null {
if (typeof v === 'string') return v;
if (Array.isArray(v) && v.length > 0) return toImagePath(v[0]);
if (v && typeof v === 'object') {
const o = v as JsonLdNode;
if (typeof o.url === 'string') return o.url;
}
return null;
}
function toStringArray(v: unknown): string[] {
if (Array.isArray(v)) return v.map((x) => toText(x)).filter((x): x is string => x !== null);
if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
return [];
}
function toSteps(v: unknown): Step[] {
const out: Step[] = [];
const walk = (x: unknown) => {
if (Array.isArray(x)) {
for (const item of x) walk(item);
return;
}
if (typeof x === 'string') {
if (x.trim()) out.push({ position: out.length + 1, text: x.trim() });
return;
}
if (x && typeof x === 'object') {
const obj = x as JsonLdNode;
if (obj['@type'] === 'HowToSection' && obj.itemListElement) {
walk(obj.itemListElement);
return;
}
if (obj['@type'] === 'HowToStep' && typeof obj.text === 'string') {
out.push({ position: out.length + 1, text: obj.text.trim() });
return;
}
if (typeof obj.text === 'string') {
out.push({ position: out.length + 1, text: obj.text.trim() });
}
}
};
walk(v);
return out;
}
function toServings(v: unknown): number | null {
if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v);
if (typeof v === 'string') {
const m = /(\d+)/.exec(v);
if (m) return parseInt(m[1], 10);
}
if (Array.isArray(v) && v.length > 0) return toServings(v[0]);
return null;
}
function findRecipeNode(html: string): JsonLdNode | null {
const { document } = parseHTML(html);
const scripts = document.querySelectorAll('script[type="application/ld+json"]');
for (const script of scripts) {
const raw = script.textContent;
if (!raw) continue;
try {
const parsed = JSON.parse(raw);
for (const node of unwrapGraph(parsed)) {
if (isRecipeType(node['@type'])) return node;
}
} catch {
// ignore malformed JSON-LD blocks and continue
}
}
return null;
}
export function extractRecipeFromHtml(html: string): Recipe | null {
const node = findRecipeNode(html);
if (!node) return null;
const title = toText(node.name) ?? '';
if (!title) return null;
const ingredients = Array.isArray(node.recipeIngredient)
? (node.recipeIngredient as unknown[])
.map((x, i) => (typeof x === 'string' ? parseIngredient(x, i + 1) : null))
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const steps = toSteps(node.recipeInstructions);
const imageUrl = toImagePath(node.image);
const prep = parseIso8601Duration(node.prepTime as string | undefined);
const cook = parseIso8601Duration(node.cookTime as string | undefined);
const total = parseIso8601Duration(node.totalTime as string | undefined);
const tags = new Set<string>([
...toStringArray(node.recipeCategory),
...toStringArray(node.recipeCuisine),
...toStringArray(node.keywords)
]);
return {
id: null,
title,
description: toText(node.description),
source_url: typeof node.url === 'string' ? node.url : null,
source_domain: null, // filled by importer
image_path: imageUrl, // here it's still a URL, importer downloads & replaces
servings_default: toServings(node.recipeYield),
servings_unit: null,
prep_time_min: prep,
cook_time_min: cook,
total_time_min: total,
cuisine: toText(node.recipeCuisine),
category: toText(node.recipeCategory),
ingredients,
steps,
tags: [...tags]
};
}
- Step 5: Run — should pass
npm test -- json-ld-recipe
If a fixture fails, inspect the HTML and adjust the extractor — do not alter the test.
- Step 6: Commit
git add src/lib/server/parsers/json-ld-recipe.ts tests/unit/json-ld-recipe.test.ts tests/fixtures/
git commit -m "feat(parser): add JSON-LD schema.org/Recipe extractor"
Task 8: Database schema and migration runner
Files:
-
Create:
src/lib/server/db/migrate.ts,src/lib/server/db/index.ts,src/lib/server/db/migrations/001_init.sql -
Test:
tests/integration/db.test.ts -
Step 1: Write schema file
-- src/lib/server/db/migrations/001_init.sql
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
);
-- FTS5 virtual table
CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5(
title, description, ingredients_concat, tags_concat,
content='', tokenize='unicode61 remove_diacritics 2'
);
-- Triggers to keep FTS in sync with recipe + ingredient + recipe_tag
-- Strategy: rebuild per-recipe rows via triggers. Simpler than incremental.
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;
-- Ingredient & tag changes trigger full FTS row refresh for the recipe
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;
-- Meta table for tracking applied migrations
CREATE TABLE IF NOT EXISTS schema_migration (
name TEXT PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
- Step 2: Write migration runner
// src/lib/server/db/migrate.ts
import 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().map((r: any) => 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();
}
}
- Step 3: Write db helper
// src/lib/server/db/index.ts
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;
}
- Step 4: Write integration test
// tests/integration/db.test.ts
import { describe, it, expect } from 'vitest';
import { openInMemoryForTest } from '../../src/lib/server/db';
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()
.map((r: any) => 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();
// run again manually — should no-op
const { runMigrations } = require('../../src/lib/server/db/migrate');
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 id = 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(id.id, 1, 'Salz');
db.prepare('INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)').run(id.id, 1, 'Kochen');
db.prepare('DELETE FROM recipe WHERE id = ?').run(id.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);
});
});
- Step 5: Update vite.config.ts to include integration tests
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['tests/**/*.test.ts'],
globals: false,
environment: 'node',
testTimeout: 10_000
}
});
- Step 6: Run tests
npm test
Expected: all previous tests + the 3 db tests pass.
- Step 7: Commit
git add -A
git commit -m "feat(db): add SQLite schema, FTS5, migration runner"
Task 9: Health endpoint
Files:
-
Create:
src/routes/api/health/+server.ts -
Step 1: Implement
// src/routes/api/health/+server.ts
import type { RequestHandler } from './$types';
import { getDb } from '$lib/server/db';
import { json } from '@sveltejs/kit';
export const GET: RequestHandler = async () => {
let dbOk = false;
try {
const db = getDb();
db.prepare('SELECT 1').get();
dbOk = true;
} catch {
dbOk = false;
}
const status = dbOk ? 200 : 503;
return json(
{ db: dbOk ? 'ok' : 'error', searxng: 'unchecked', version: '0.1.0' },
{ status }
);
};
- Step 2: Smoke-test manually
Ensure data/ exists:
mkdir -p data
npm run dev
In another terminal:
curl -i http://localhost:5173/api/health
Expected: HTTP/1.1 200 OK + {"db":"ok","searxng":"unchecked","version":"0.1.0"}.
Stop dev server.
- Step 3: Commit
git add src/routes/api/health/+server.ts
git commit -m "feat(api): add /api/health endpoint"
Task 10: SearXNG Docker Compose (dev only)
Files:
-
Create:
docker-compose.yml,searxng/settings.yml,.env.example -
Step 1: Write compose & config
docker-compose.yml:
services:
searxng:
image: searxng/searxng:latest
ports:
- "8888:8080"
volumes:
- ./searxng:/etc/searxng
environment:
- BASE_URL=http://localhost:8888/
- INSTANCE_NAME=kochwas-search-dev
restart: unless-stopped
searxng/settings.yml (minimal, allows JSON output):
use_default_settings: true
server:
secret_key: 'dev-secret-change-in-prod'
limiter: false
image_proxy: false
default_http_headers:
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Robots-Tag: noindex, nofollow
search:
formats:
- html
- json
ui:
default_locale: de
.env.example:
DATABASE_PATH=./data/kochwas.db
IMAGE_DIR=./data/images
SEARXNG_URL=http://localhost:8888
- Step 2: Smoke-test
docker compose up -d searxng
sleep 5
curl -s "http://localhost:8888/search?q=carbonara&format=json" | head -c 300
docker compose down
Expected: JSON response with search results.
- Step 3: Commit
git add docker-compose.yml searxng/ .env.example
git commit -m "feat(infra): add SearXNG dev container"
Task 11: Basic homepage with search input
Files:
- Modify:
src/routes/+page.svelte
Minimal placeholder so we have something to open in the browser. Real search lands in Phase 3.
- Step 1: Implement
<!-- src/routes/+page.svelte -->
<script lang="ts">
let query = '';
</script>
<main>
<h1>Kochwas</h1>
<form method="GET" action="/search">
<input
type="search"
name="q"
bind:value={query}
placeholder="Rezept suchen…"
autocomplete="off"
inputmode="search"
/>
<button type="submit">Suchen</button>
</form>
<p class="muted">Phase 1: Foundations — Suche folgt in Phase 3.</p>
</main>
<style>
main {
max-width: 640px;
margin: 4rem auto;
padding: 0 1rem;
font-family: system-ui, -apple-system, sans-serif;
}
h1 {
text-align: center;
font-size: 3rem;
margin-bottom: 2rem;
}
form {
display: flex;
gap: 0.5rem;
}
input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1.1rem;
border: 1px solid #bbb;
border-radius: 8px;
}
button {
padding: 0.75rem 1.25rem;
font-size: 1.1rem;
border-radius: 8px;
border: 0;
background: #2b6a3d;
color: white;
}
.muted {
color: #888;
font-size: 0.9rem;
text-align: center;
margin-top: 2rem;
}
</style>
- Step 2: Smoke-test
npm run dev
Open http://localhost:5173/, see the search box. Stop dev.
- Step 3: Commit
git add src/routes/+page.svelte
git commit -m "feat(ui): add minimal homepage with search input"
Phase 1 Done-When
npm testgreen (all unit + integration tests)npm run checkgreen (TypeScript clean)npm run devstarts the app;http://localhost:5173/renders the search inputcurl http://localhost:5173/api/healthreturns{"db":"ok",…}with 200docker compose up -d searxngbrings SearXNG up; JSON endpoint respondsdocker compose downcleans up- All tasks committed on
main