119 lines
4.4 KiB
Markdown
119 lines
4.4 KiB
Markdown
# 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.
|