From c7bff7937146024c223ab9c83b8c2c8558524ab5 Mon Sep 17 00:00:00 2001 From: James Brechtel Date: Tue, 19 May 2026 22:53:55 -0400 Subject: [PATCH] Add design doc for live recipe watching via fsnotify --- .../2026-05-19-live-recipe-watching-design.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/specs/2026-05-19-live-recipe-watching-design.md diff --git a/docs/specs/2026-05-19-live-recipe-watching-design.md b/docs/specs/2026-05-19-live-recipe-watching-design.md new file mode 100644 index 0000000..ad55f14 --- /dev/null +++ b/docs/specs/2026-05-19-live-recipe-watching-design.md @@ -0,0 +1,118 @@ +# 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.