Add design doc for live recipe watching via fsnotify

This commit is contained in:
2026-05-19 22:53:55 -04:00
parent 1addddd8fc
commit c7bff79371
@@ -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.