# 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** ```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** ```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** ```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** ```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`: ```json { "useTabs": false, "singleQuote": true, "trailingComma": "none", "printWidth": 100, "plugins": ["prettier-plugin-svelte"], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } ``` `src/app.html`: ```html Kochwas %sveltekit.head%
%sveltekit.body%
``` `src/app.d.ts`: ```ts declare global { namespace App {} } export {}; ``` `src/routes/+layout.svelte`: ```svelte ``` `src/routes/+page.svelte`: ```svelte

Kochwas

Coming soon.

``` - [ ] **Step 6: Install & verify** Run: ```bash npm install npx svelte-kit sync npm run check ``` Expected: no type errors. - [ ] **Step 7: Commit** ```bash 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** ```ts // tests/unit/smoke.test.ts import { describe, it, expect } from 'vitest'; describe('smoke', () => { it('runs', () => { expect(1 + 1).toBe(2); }); }); ``` - [ ] **Step 2: Run** ```bash npm test ``` Expected: 1 test passes. - [ ] **Step 3: Commit** ```bash 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** ```ts // 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** ```bash npm test -- iso8601-duration ``` Expected: fails with "parseIso8601Duration is not a function" or similar. - [ ] **Step 3: Implement** ```ts // 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** ```bash npm test -- iso8601-duration ``` Expected: all tests pass. - [ ] **Step 5: Commit** ```bash 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** ```ts // 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** ```bash 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** ```ts // 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** ```ts // 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 = { '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] // 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** ```bash 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** ```ts // 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** ```ts // 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** ```bash 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): ```bash 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** ```ts // 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 = '

no recipe

'; expect(extractRecipeFromHtml(html)).toBeNull(); }); }); ``` - [ ] **Step 3: Run — should fail** - [ ] **Step 4: Implement** ```ts // 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; 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 => 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([ ...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** ```bash npm test -- json-ld-recipe ``` If a fixture fails, inspect the HTML and adjust the extractor — do not alter the test. - [ ] **Step 6: Commit** ```bash 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** ```sql -- 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** ```ts // 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** ```ts // 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** ```ts // 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** ```ts // 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** ```bash npm test ``` Expected: all previous tests + the 3 db tests pass. - [ ] **Step 7: Commit** ```bash 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** ```ts // 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: ```bash mkdir -p data npm run dev ``` In another terminal: ```bash 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** ```bash 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`: ```yaml 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): ```yaml 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** ```bash 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** ```bash 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** ```svelte

Kochwas

Phase 1: Foundations — Suche folgt in Phase 3.

``` - [ ] **Step 2: Smoke-test** ```bash npm run dev ``` Open `http://localhost:5173/`, see the search box. Stop dev. - [ ] **Step 3: Commit** ```bash git add src/routes/+page.svelte git commit -m "feat(ui): add minimal homepage with search input" ``` --- ## Phase 1 Done-When - `npm test` green (all unit + integration tests) - `npm run check` green (TypeScript clean) - `npm run dev` starts the app; `http://localhost:5173/` renders the search input - `curl http://localhost:5173/api/health` returns `{"db":"ok",…}` with 200 - `docker compose up -d searxng` brings SearXNG up; JSON endpoint responds - `docker compose down` cleans up - All tasks committed on `main`