# Live Recipe Watching **Date:** 2026-05-19 **Status:** Approved ## Problem The server loads all recipes once at startup and serves them read-only for the duration of the server process. Since the recipe directory is the source of truth, any edits to `.cook` files on disk require a server restart to be reflected on the site. ## Goal Watch the recipe directory for file changes (additions, modifications, deletions) and update the in-memory recipe index live, without a restart. ## Non-Goals - Real-time push to open browser tabs (a page refresh will show new content). - Watching subdirectories (all recipes are flat in one directory). - Watcher restart on failure (log and let it die; restart the container if needed). ## Architecture ### State Replace the current `[RecipeInfo]` closure with an `IORef (Map FilePath RecipeInfo)`: - **`IORef`** — provides atomic reads and swaps; in `base`, no extra dependency. - **`Map`** — `Data.Map.Strict`, already a project dependency. - **Key** — lowercased filename (e.g., `"pad-thai.cook"`) for O(log n) case-insensitive lookups, matching the current `lookupRecipe` behavior. - **Value** — the existing `RecipeInfo`, which includes the original (mixed-case) filename in `riFilename`. ### Watcher Thread A `forkIO`'d thread uses the `fsnotify` package to subscribe to all events on the recipe directory. Three event types are handled: | Event | Action | |------------|-----------------------------------------------------| | `Added` | Read & parse the file → upsert in the Map | | `Modified` | Read & parse the file → upsert in the Map | | `Removed` | Delete from the Map | All mutations go through `atomicModifyIORef'` so HTTP handlers always see a consistent snapshot. ### Debouncing Text editors fire multiple filesystem events per save (e.g., delete+create for atomic saves, multiple metadata events). A simple 200ms debounce collects events from `fsnotify`'s callback into a set, then processes them after 200ms of silence. This prevents redundant re-parses. ### Startup Flow 1. Scan the recipe directory with `Idx.scanRecipes` (same as today). 2. Build the initial `IORef (Map FilePath RecipeInfo)`. 3. `forkIO` the watcher thread. 4. Return the WAI `Application` (now closing over the `IORef`). ### Request Handling - `handleRequest` reads from the `IORef` to get the current `[RecipeInfo]` (via `Map.elems`) or a single `RecipeInfo` (via `Map.lookup`). - The index page continues to receive `[RecipeInfo]` and re-renders on each request — no caching changes needed. - `lookupRecipe` becomes a `Map.lookup` with a normalized key instead of a linear scan. ### Error Handling - **Parse error on modification:** log the error (with `[roux]` prefix), keep the old recipe in the Map. The last valid version stays accessible. - **File read error (permissions, race):** log and skip. - **Watcher thread crash:** log and let it die. The server continues serving the last known state. A container restart would restore watching. ## Dependencies Add `fsnotify` to `package.yaml` (and the `.cabal` file via `hpack`). No new concurrency libraries needed — `IORef` and `forkIO` are in `base`. ## Changes by File ### `package.yaml` - Add `fsnotify` to library dependencies. ### `src/Roux/RecipeIndex.hs` - No changes (the scanning/loading functions are already correct). ### `src/Roux/Server.hs` - `app :: FilePath -> IO Wai.Application` — same signature, new internals: - Return type is already `IO Wai.Application`, so signature stays the same. - Scan recipes → populate `IORef`. - Fork watcher thread (with debounce). - Return handler closing over the `IORef`. - `handleRequest :: IORef (Map FilePath RecipeInfo) -> Wai.Application` — updated to accept the `IORef`. - `lookupRecipe :: FilePath -> Map FilePath RecipeInfo -> Maybe RecipeInfo` — simplified to a `Map.lookup` with normalized key. - New module-level imports: `Data.IORef`, `Control.Concurrent (forkIO)`, `System.FSNotify`, `Data.Map.Strict`. ### `app/Main.hs` - No changes needed (it just calls `Roux.app` as before). ## Testing - **Manual test:** edit a `.cook` file while the server runs and confirm the change is reflected on a page refresh. - **Invalid recipe test:** introduce a parse error and confirm the old version stays live. - **Delete test:** remove a `.cook` file and confirm a 404 on refresh.