diff --git a/README.md b/README.md index e694dfa..8852fe4 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,10 @@ npm ci python3 -m pip install --user -r requirements-dev.txt ``` -## Database +## Database & Backups SQLite is the canonical writable store. The database is located at -`var/recipe-book.sqlite` and is intentionally excluded from Git. The single -baseline in `migrations/001_initial.sql` defines its complete schema. +`var/recipe-book.sqlite` and is intentionally excluded from Git. All normal recipe, ingredient, nutrition-mapping, and purchasing changes must be written to SQLite through the application or its validated database @@ -51,26 +50,34 @@ edit `culinary/*.yaml` as a way to update a running application, and do not use unrestricted SQL when `saveRecipeStructure()` or another domain save function is available. -Create a new local database from the portable culinary dataset: +### Backup and Restore + +To create a full, verifiable JSON backup of the active database: ```bash -npm run db:reset +npm run db:backup -- [path/to/backup.json] ``` -**Warning:** this command deletes and replaces the existing local database with -the contents of `culinary/`. Any newer SQLite-only edits will be lost. There is -intentionally no legacy upgrade chain. YAML under `culinary/` is retained as -portable seed and interchange data; it is not a second writable source of -truth. +To validate a backup bundle without modifying the database: + +```bash +npm run db:validate -- path/to/backup.json +``` + +To restore a backup into SQLite (transactional replace mode): + +```bash +npm run db:restore -- path/to/backup.json +``` + +Backups can also be downloaded and restored interactively through the web UI at `/app/settings/`. Generated projections and future YAML/JSON exports flow outward from SQLite. They are suitable for presentation, backup, interchange, and Git review, but must not be edited independently and treated as authoritative. See [Local application](docs/local-application.md) for more detail. -For moving development to another machine, including the distinction between a -seed rebuild and transferring current SQLite data, see -[Agent handoff](docs/agent-handoff.md). +For moving development to another machine, see [Agent handoff](docs/agent-handoff.md). ## Development diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index cb04328..9054c81 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -10,28 +10,31 @@ interchange data, not the live editing surface. Application and automated edits should use validated domain functions and transactions rather than unrestricted SQL or direct YAML changes. -## Start from the committed seed data +## Database Management + +SQLite (`var/recipe-book.sqlite`) is the canonical writable store. +Application and automated edits must use validated domain functions and transactions +rather than direct YAML changes or unrestricted SQL. + +### Starting the Application ```sh npm ci -npm run db:reset npm run dev:app ``` -`db:reset` deletes the local database before importing `culinary/`. Do not run it -when a newer SQLite database has been transferred from another installation. +### Backups and Transfers -## Transfer the latest application data +The runtime database lives under `var/`, which is intentionally ignored by Git. -The runtime database and its backups live under `var/`, which is intentionally -ignored by Git. A clone therefore contains the application and portable seed, -but not necessarily the latest recipe edits. - -To hand off the current live state, create a consistent SQLite backup separately -from Git: +To backup or hand off the current live state: ```sh -npm run db:backup -- /safe/transfer/recipe-book.sqlite +# Export full JSON backup +npm run db:backup -- var/recipe-book-backup.json + +# Restore database from backup on receiving machine +npm run db:restore -- var/recipe-book-backup.json ``` Place the transferred file at `var/recipe-book.sqlite` on the receiving machine. diff --git a/docs/api-and-mcp-guide.md b/docs/api-and-mcp-guide.md new file mode 100644 index 0000000..5c45960 --- /dev/null +++ b/docs/api-and-mcp-guide.md @@ -0,0 +1,320 @@ +# Formulation API & Model Context Protocol (MCP) Guide + +Formulation exposes two programmatic interfaces for querying, scaling, costing, and manipulating culinary data: +1. **Model Context Protocol (MCP) Server**: A standard stdio JSON-RPC server enabling AI assistants (Claude Desktop, Antigravity, Cursor, Gemini) to directly search, retrieve, cost, scale, and save recipes, ingredients, books, and inventory counts. +2. **REST Web API (`/api/v1/`)**: HTTP JSON endpoints for external systems, webhooks, and programmatic integrations. + +--- + +## 1. Model Context Protocol (MCP) Server + +### Starting the Server +```bash +npm run mcp +# or directly: +node scripts/mcp-server.mjs +``` + +### Client Configuration Examples + +#### Claude Desktop (`%APPDATA%\Claude\claude_desktop_config.json` on Windows / `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS) +```json +{ + "mcpServers": { + "formulation": { + "command": "node", + "args": [ + "C:\\Users\\nicholas\\Documents\\repos\\formulation\\scripts\\mcp-server.mjs" + ] + } + } +} +``` + +#### Antigravity / Gemini (`.gemini/config/mcp_config.json`) +```json +{ + "mcpServers": { + "formulation": { + "command": "node", + "args": [ + "scripts/mcp-server.mjs" + ] + } + } +} +``` + +#### Cursor (`.cursor/mcp.json`) +```json +{ + "mcpServers": { + "formulation": { + "command": "node", + "args": ["scripts/mcp-server.mjs"] + } + } +} +``` + +--- + +### MCP Tools Reference (15 Domain Tools) + +| Tool | Parameters | Description | +| --- | --- | --- | +| `search_recipes` | `query?`, `category?`, `tag?`, `limit?` | Search recipes by keyword, category, or tag | +| `get_recipe` | `id`, `scale_factor?`, `target_yield?`, `target_yield_unit?` | Retrieve full recipe formulation with optional scaling | +| `save_recipe` | `id?`, `title`, `yield_quantity`, `yield_unit_id`, `components`, `steps`, `notes?` | Create or update a recipe formulation with validation | +| `delete_recipe` | `id` | Safely archive a recipe from the active library | +| `calculate_recipe_cost` | `recipe_id`, `currency?` | Compute itemized ingredient & sub-recipe cost breakdown | +| `calculate_recipe_nutrition` | `recipe_id`, `serving_size_g?` | Compute nutrition facts panel (macros/micros) | +| `search_ingredients` | `query?`, `category?`, `missing_cost?`, `limit?` | Search ingredients in the pantry catalog | +| `get_ingredient` | `id` | Get ingredient detail with density, conversions, and prices | +| `save_ingredient` | `id?`, `name`, `description?`, `categories?`, `tags?`, `aliases?`, `density?` | Create or update an ingredient with density & aliases | +| `delete_ingredient` | `id` | Safely archive an ingredient (blocks if used in active recipes) | +| `convert_units` | `ingredient_id?`, `quantity`, `from_unit`, `to_unit` | Convert culinary units safely using density data | +| `list_inventory_counts` | `status?` (`'all' \| 'open' \| 'completed'`) | List inventory counting sessions | +| `get_inventory_count` | `id` | Get full count sheet with locations, items, unit costs, and valuations | +| `list_recipe_books` | *(none)* | List recipe books (collections) with recipe counts | +| `get_recipe_book` | `id` | Get recipe book details with ordered included recipes | +| `save_recipe_book` | `id?`, `name`, `description?`, `recipe_ids?` | Create or update a recipe book / collection | +| `export_database_backup` | *(none)* | Export complete JSON backup of all 25 SQLite tables | +| `get_database_stats` | *(none)* | Get table entity counts across the database | + +--- + +## 2. REST Web API Specification (`/api/v1/`) + +All REST endpoints return standard JSON envelopes: +- Success: `{ "success": true, "data": ... }` +- Error: `{ "success": false, "error": "Description" }` + +### Recipes + +#### List Recipes +```http +GET /api/v1/recipes?q=biscotti&limit=10 +``` + +#### Get Recipe Details & Dynamic Scaling +```http +GET /api/v1/recipes/chocolate_biscotti?scale=2 +``` + +#### Calculate Scaled Quantities +```http +POST /api/v1/recipes/chocolate_biscotti/scale +Content-Type: application/json + +{ + "target_yield": 100, + "target_yield_unit": "each" +} +``` + +#### Itemized Cost Rollup +```http +GET /api/v1/recipes/chocolate_biscotti/cost?currency=USD +``` + +#### Nutrition Facts Rollup +```http +GET /api/v1/recipes/chocolate_biscotti/nutrition +``` + +#### Create Recipe +```http +POST /api/v1/recipes +Content-Type: application/json + +{ + "title": "Vanilla Glaze", + "yield_quantity": 250, + "yield_unit_id": "gram", + "yield_servings": 10, + "categories": ["sauce"], + "components": [ + { + "name": "Main", + "items": [ + { "ingredient_id": "confectioners_sugar", "quantity": 200, "unit_id": "gram" }, + { "ingredient_id": "milk", "quantity": 45, "unit_id": "gram" }, + { "ingredient_id": "vanilla_extract", "quantity": 5, "unit_id": "gram" } + ] + } + ], + "steps": [ + { "instruction": "In a medium bowl, whisk confectioners sugar, milk, and vanilla extract until smooth." } + ] +} +``` + +#### Update Recipe +```http +PUT /api/v1/recipes/vanilla_glaze +``` + +#### Archive Recipe +```http +DELETE /api/v1/recipes/vanilla_glaze +``` + +--- + +### Ingredients + +#### List Ingredients +```http +GET /api/v1/ingredients?q=sugar&limit=10 +``` + +#### Get Ingredient Details +```http +GET /api/v1/ingredients/sugar +``` + +#### Create Ingredient +```http +POST /api/v1/ingredients +Content-Type: application/json + +{ + "name": "Matcha Powder", + "description": "Ceremonial Japanese green tea powder", + "categories": ["tea", "flavoring"], + "tags": ["japanese", "beverage"], + "aliases": [{ "name": "Matcha" }], + "density": { + "mass_quantity": 60, + "mass_unit_id": "gram", + "volume_quantity": 0.25, + "volume_unit_id": "cup_us" + } +} +``` + +#### Update Ingredient +```http +PUT /api/v1/ingredients/matcha_powder +``` + +#### Archive Ingredient +```http +DELETE /api/v1/ingredients/matcha_powder +``` +*(Fails safely with HTTP 409 Conflict if ingredient is referenced by active recipes.)* + +--- + +### Recipe Books / Collections + +#### List Recipe Books +```http +GET /api/v1/collections +``` + +#### Get Recipe Book +```http +GET /api/v1/collections/baking_essentials +``` + +#### Create Recipe Book +```http +POST /api/v1/collections +Content-Type: application/json + +{ + "name": "Holiday Pastries", + "description": "Seasonal baked goods collection", + "recipe_ids": ["chocolate_biscotti", "cinnamon_sugar"] +} +``` + +#### Update Recipe Book +```http +PUT /api/v1/collections/holiday_pastries +``` + +#### Archive Recipe Book +```http +DELETE /api/v1/collections/holiday_pastries +``` + +--- + +### Inventory Counts + +#### List Counting Sessions +```http +GET /api/v1/inventory/counts?status=all +``` + +#### Start New Count Session +```http +POST /api/v1/inventory/counts +Content-Type: application/json + +{ + "title": "August End-of-Month Count", + "counted_at": "2026-08-31T18:00:00Z", + "notes": "Full kitchen and pantry audit", + "prepopulate": true +} +``` + +#### Get Count Sheet & Extended Valuation +```http +GET /api/v1/inventory/counts/count_2026_08_week3 +``` + +#### Save Count Items & Update Status +```http +PUT /api/v1/inventory/counts/count_2026_08_week3 +Content-Type: application/json + +{ + "status": "completed", + "items": [ + { "ingredient_id": "flour_all_purpose", "location_id": "loc_dry_storage", "quantity": 15000, "unit_id": "gram" }, + { "ingredient_id": "sugar", "location_id": "loc_dry_storage", "quantity": 25, "unit_id": "pound" } + ] +} +``` + +--- + +### Unit Conversions + +```http +POST /api/v1/convert +Content-Type: application/json + +{ + "ingredient_id": "salt", + "quantity": 2, + "from_unit": "tbsp", + "to_unit": "gram" +} +``` +```json +{ + "success": true, + "data": { + "from": { "quantity": 2, "unit_id": "tbsp" }, + "to": { "quantity": 36.52, "unit_id": "gram" }, + "ingredient_id": "salt", + "method": "ingredient_measure_conversion" + } +} +``` + +--- + +### Database Backups + +```http +GET /api/app/backup/export +POST /api/app/backup/validate +POST /api/app/backup/import?mode=replace|merge +``` diff --git a/docs/help/inventory/count-sheets-and-locations.md b/docs/help/inventory/count-sheets-and-locations.md index cf1ac17..0d1ba1e 100644 --- a/docs/help/inventory/count-sheets-and-locations.md +++ b/docs/help/inventory/count-sheets-and-locations.md @@ -1,12 +1,12 @@ # Count Sheets & Storage Locations -Inventory in Formulation is built for high-speed, sheet-to-shelf counting across physical storage locations. +Inventory in Formulation is designed for fast, sheet-to-shelf counting across physical kitchen storage locations. --- ## 1. Storage Locations -Organize your kitchen into logical physical zones: +Organize physical storage areas into logical zones: - **Walk-in Cooler**: Dairy, produce, raw proteins, prepped batch items. - **Dry Storage**: Flours, grains, spices, oils, canned goods. - **Freezer**: Frozen stocks, puff pastry, frozen proteins. @@ -17,8 +17,10 @@ Organize your kitchen into logical physical zones: ## 2. Conducting an Inventory Count -1. **Start a Count Session**: Open `/app/inventory/` and click **+ New Count Session**. -2. **Sheet-to-Shelf Counting**: Open the count sheet and filter by physical location. Items appear in the exact physical order of your storage shelves. -3. **Enter On-Hand Quantities**: Type counted units (e.g. `4.5` bags, `12` each, `25` lbs). -4. **Live Valuation**: Formulation instantly computes the **Extended Value ($)** for each item based on current vendor purchase costs. -5. **Complete & Finalize**: Submit the count to freeze period-end inventory valuation. +To conduct a count session: +1. Go to **Inventory** in the directory toolbar. +2. Select **+ New Count Session**. +3. In the count session view, select a location filter to display items in shelf order. +4. For each line item, enter the on-hand quantity in the **Count** box. +5. Review the **Extended Value ($)** column, which automatically computes the value based on current vendor purchase costs. +6. Select **Finalize Count** to complete the count and lock the valuation for accounting. diff --git a/docs/help/recipes/bakers-percentages.md b/docs/help/recipes/bakers-percentages.md index 4581ec2..177e6dd 100644 --- a/docs/help/recipes/bakers-percentages.md +++ b/docs/help/recipes/bakers-percentages.md @@ -1,6 +1,6 @@ # Baker's & Standard Percentages -In baking and commercial food manufacturing, formulas are often structured using **percentages** to ensure recipe scalability and hydration control. +In baking and commercial food manufacturing, formulas use **percentages** to ensure recipe scalability and hydration control. --- @@ -9,18 +9,20 @@ In baking and commercial food manufacturing, formulas are often structured using ### Standard % (Total Formulation Basis) $$\text{Standard \%} = \frac{\text{Ingredient Weight}}{\text{Total Batch Weight}} \times 100$$ - In Standard Percentage mode, the sum of all ingredient percentages in the recipe equals **100%**. -- Useful for confectionery, dressings, beverages, and general culinary batching. +- Use Standard % for confectionery, dressings, beverages, and general culinary batching. ### Baker's % (Flour / Basis Member Basis) $$\text{Baker's \%} = \frac{\text{Ingredient Weight}}{\text{Total Basis Flour Weight}} \times 100$$ -- In Baker's Percentage mode, the flour(s) or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**. -- All other ingredients (water/hydration, salt, yeast, sugar, butter) are expressed as a percentage relative to the total flour weight (e.g. 75% hydration water, 2% salt, 1.5% yeast). +- In Baker's Percentage mode, the flour or designated base ingredients are flagged as **Base Members** (`basis_member = true`) and sum to **100%**. +- All other ingredients (such as hydration water, salt, yeast, sugar, and butter) are expressed as a percentage relative to the total flour weight (such as 75% hydration water, 2% salt, 1.5% yeast). --- -## 2. Dynamic Interactive Percentage Editing +## 2. Using Interactive Percentage Editing -When editing in the Recipe Structure Editor: -1. Enable **Calculate %** and select **Baker's %** or **Standard %**. -2. For Baker's %, toggle the **Base** checkbox on the flour/grain ingredients. -3. Editing an ingredient's percentage dynamically recalibrates its required physical weight and quantity in grams automatically! +To configure and edit percentages in a recipe: +1. Open the recipe in edit mode. +2. Turn on the **Calculate %** toggle. +3. Select **Standard %** or **Baker's %**. +4. For Baker's %, select the **Base** check box for each flour or grain ingredient. +5. In the **%** column, enter the desired percentage for an ingredient. Formulation dynamically calculates the required physical weight and quantity in grams. diff --git a/docs/help/recipes/scaling-and-yields.md b/docs/help/recipes/scaling-and-yields.md index 16fa3c8..3baefef 100644 --- a/docs/help/recipes/scaling-and-yields.md +++ b/docs/help/recipes/scaling-and-yields.md @@ -1,6 +1,6 @@ # Scaling, Batching & Yield Calculations -Formulation is a weight-first formulation engine designed to scale recipes accurately across commercial batch sizes without calculation rounding drift. +Formulation is a weight-first formulation engine designed to scale recipes across commercial batch sizes without calculation rounding drift. --- @@ -8,27 +8,34 @@ Formulation is a weight-first formulation engine designed to scale recipes accur Recipes can be scaled in two primary modes: -### A. Batch Multiplier (`x` factor) -- Entering a batch multiplier (e.g. `0.5x`, `2x`, `5x`, `10x`) multiplies every ingredient quantity by the exact factor. -- Pre-configured quick buttons allow instant one-tap scaling during active kitchen prep. +### A. Batch Multiplier (`x` Factor) +To scale by a batch multiplier: +1. Open the recipe in view mode or edit mode. +2. In the **Batch** field, enter a multiplier value (such as `0.5`, `2`, `5`, or `10`). +3. Every ingredient quantity scales proportionally by the exact factor. ### B. Target Yield Scaling -- Specify an exact required total batch yield (e.g., scale a sauce recipe to yield exactly `1,500 grams` or `4.5 quarts`). -- Formulation calculates the precise scale factor required to produce that yield based on total recipe weight. +To scale to a specific finished yield target: +1. Open the recipe. +2. In the **Yield** field, enter the target finished quantity. +3. Select the desired yield unit from the unit list. +4. Formulation computes the required scale factor based on total recipe weight and updates all ingredient quantities immediately. --- ## 2. Weight-Based Auto-Yield Calculation -When **Auto-Calculate Total Yield** is toggled ON: -1. Formulation calculates the weight in grams for every ingredient in the recipe using canonical unit conversion factors or ingredient-specific density measurements. -2. The total recipe yield quantity is automatically updated as the exact sum of all ingredient weights. -3. If an ingredient cannot be converted to weight (due to a missing UoM volume-to-weight equivalency), an informative notice displays: *"Auto yield excludes N ingredient amounts without a weight equivalency."* +To enable automatic total yield calculation: +1. Open the recipe in edit mode. +2. Turn on the **Auto calculate total yield** toggle. +3. Formulation calculates the weight in grams for every ingredient using standard conversion factors or ingredient-specific density measurements. +4. The total recipe yield quantity updates automatically to equal the exact sum of all ingredient weights. +5. If an ingredient lacks a volume-to-weight equivalency, a notice appears: *"Auto yield excludes N ingredient amounts without a weight equivalency."* --- ## 3. Unit Conversion Safety -- Ingredients measured in mass (e.g. `g`, `kg`, `oz`, `lb`) convert losslessly across all mass units. -- Ingredients measured in volume (e.g. `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (e.g. `1 cup = 120g`) to convert to mass. -- Cross-dimensional universal conversions without density data are intentionally rejected to maintain strict culinary accuracy. +- Ingredients measured in mass (such as `g`, `kg`, `oz`, `lb`) convert directly across all mass units. +- Ingredients measured in volume (such as `cup`, `tbsp`, `tsp`, `liter`, `ml`) require an ingredient density measurement (such as `1 cup = 120 g`) to convert to mass. +- Cross-dimensional conversions without density data are rejected to maintain strict culinary accuracy. diff --git a/docs/help/recipes/sub-recipes-and-prep.md b/docs/help/recipes/sub-recipes-and-prep.md index e19a870..7e41fbb 100644 --- a/docs/help/recipes/sub-recipes-and-prep.md +++ b/docs/help/recipes/sub-recipes-and-prep.md @@ -1,23 +1,45 @@ # Sub-recipes & Prep Methods -Recipes in Formulation can seamlessly nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost/nutrition rollup. +Recipes in Formulation can nest other recipes as **sub-recipes**, enabling modular batch preparation and accurate cost and nutrition rollup. --- ## 1. Using Sub-recipes in Formulations -- **Nesting**: When adding an ingredient to a recipe row, you can select an existing Recipe (tagged with the blue Recipe badge) rather than a raw ingredient. -- **Cascading Cost & Nutrition**: - - The sub-recipe's unit cost and nutritional profile are calculated based on its own ingredients and yield, and cascaded into the parent recipe. - - Changes to a base sub-recipe (e.g. *House Mayonnaise*) automatically propagate up to all dishes that include it (e.g. *Aioli*, *Tartar Sauce*, *Sandwich Spread*). +To add a sub-recipe to a recipe: +1. In the recipe editor, go to the **Formula** section. +2. In the ingredient search box, enter the name of the existing recipe. +3. From the search results, select the recipe (identified by the blue **Recipe** badge). +4. Enter the required quantity and select the unit of measure. + +### Cascading Cost & Nutrition +- The sub-recipe's unit cost and nutritional profile are calculated based on its own ingredients and yield, and cascaded into the parent recipe. +- Changes to a base sub-recipe (such as *House Mayonnaise*) automatically propagate up to all dishes that include it (such as *Aioli*, *Tartar Sauce*, and *Sandwich Spread*). --- ## 2. Structured Prep Method -The **Prep Method** editor allows organizing kitchen instructions into numbered, ordered steps: +The **Prep Method** editor organizes kitchen instructions into ordered, sequential steps. -- **Numbered Instructions**: Step-by-step prep directions with drag-and-drop reordering handles. -- **Section Headers**: Add intermediate headings (e.g. `To Sear:`, `Dry Mix:`, `To Garnish:`) by ending the line with a colon `:`. -- **Prep Notes**: Add inline notes (e.g. `(Let rest for 15 minutes before carving)`) by wrapping text in parentheses `(...)`. -- **Bulk Prep Import**: Paste entire text documents or recipes into the bulk import modal to automatically parse headings, numbered steps, and notes into structured cards. +### Step-by-Step Instructions +1. In the recipe editor, go to the **Prep Method** section. +2. Select **Add Step** (or press Enter) to create a new step. +3. Enter the step instructions using imperative action verbs (such as *Combine*, *Preheat*, *Whisk*, or *Bake*). +4. To reorder steps, select and drag the 6-dot drag handle to the new position. + +### Section Headings +To break complex multi-stage procedures into logical phases: +- End the step text with a colon `:` (for example, `To prepare the dough:` or `To bake:`). +- Formulation formats these entries as distinct section headers without numbered step bullets. + +### Inline Prep Notes +To add non-actionable tips or precautions: +- Wrap the text in parentheses `(...)` (for example, `(Note: Chill the dough for at least 30 minutes before rolling.)`). +- Formulation formats these entries as italicized notes without numbered step bullets. + +### Bulk Prep Import +To import an existing recipe procedure from text: +1. In the **Prep Method** header, select **Bulk Add**. +2. Paste the multi-line procedure into the text area. +3. Select **Import Steps**. Formulation automatically parses section headings, numbered steps, and notes into structured cards. diff --git a/docs/local-application.md b/docs/local-application.md index 7267545..0ad3d3a 100644 --- a/docs/local-application.md +++ b/docs/local-application.md @@ -25,11 +25,9 @@ agent request -> optional explicit export for backup or review ``` -Create the initial database from the current portable dataset with Node 22 or -newer, then run either application mode: +Run either application mode with Node 22 or newer: ```sh -npm run db:reset npm run dev:readonly npm run dev:app ``` @@ -39,16 +37,22 @@ editing controls, and rejects modifying HTTP requests. Browser-side scaling, unit conversion, nutrition, and costing calculations remain available. The database is written to `var/recipe-book.sqlite` and is intentionally ignored -by Git. `migrations/001_initial.sql` defines the complete relational schema. -The application does not support upgrading databases from older schemas: rebuild -from portable data with `npm run db:reset`. Recipe edits are transactional and a -private save token prevents stale browser tabs from overwriting newer changes. -There is no recipe revision history. +by Git. Recipe edits are transactional and a private save token prevents stale +browser tabs from overwriting newer changes. -`npm run db:import:yaml` and `npm run db:reset` both delete and replace the -database from portable YAML. They are intended only for initial setup or an -explicit restore. Running either command after application edits can discard -newer SQLite-only data. +### Backup and Restore + +To export or restore database snapshots across all 25 SQLite tables: + +```sh +# Export a JSON backup +npm run db:backup -- [path/to/backup.json] + +# Restore database from backup +npm run db:restore -- path/to/backup.json +``` + +Users can also export and restore backups interactively from the web UI at `/app/settings/`. ## Windows dev-server notes diff --git a/docs/recipe-style-guide.md b/docs/recipe-style-guide.md new file mode 100644 index 0000000..a1e2cca --- /dev/null +++ b/docs/recipe-style-guide.md @@ -0,0 +1,114 @@ +# Formulation Recipe Instruction Style Guide + +This style guide establishes procedural writing standards for culinary formulas and kitchen preparation methods in Formulation, adapting the [Microsoft Style Guide for Step-by-Step Instructions](https://learn.microsoft.com/en-us/style-guide/procedures-instructions/writing-step-by-step-instructions) and related procedural principles to commercial culinary workflows. + +--- + +## 1. Core Principles + +### A. Use Imperative Verb Forms +In technical and culinary procedures, readers scan instructions to execute immediate actions. Begin each step with a direct, active verb. + +- **Do**: "In a large bowl, whisk the flour, sugar, and baking powder." +- **Do**: "Preheat the deck oven to 450 °F (232 °C)." +- **Don't**: "Dry ingredients should be mixed." *(Passive voice)* +- **Don't**: "Mixing the dry ingredients." *(Gerund fragment)* +- **Don't**: "Next, you will want to whisk the flour..." *(Conversational filler)* + +### B. Place Conditions and Locations First +State prerequisites, equipment, or locations before the action so the cook prepares the workstation before executing the step. + +- **Do**: "In the bowl of a stand mixer fitted with the dough hook, combine the water and yeast." +- **Do**: "On a lightly floured surface, divide the dough into 8 equal portions." +- **Do**: "If the sauce begins to separate, whisk in 1 tablespoon of warm water." +- **Don't**: "Combine the water and yeast in the bowl of a stand mixer fitted with the dough hook." +- **Don't**: "Divide the dough into 8 equal portions on a lightly floured surface." + +### C. Maintain Parallel Grammatical Structure +All steps in a numbered sequence must follow a consistent grammatical pattern. + +- **Do**: + 1. Combine the dry ingredients in a large bowl. + 2. Whisk the eggs and milk in a separate pitcher. + 3. Pour the liquid mixture into the dry ingredients. +- **Don't**: + 1. Combine the dry ingredients. + 2. Eggs and milk are whisked together. + 3. Pouring the liquid into dry ingredients. + +### D. Single Action Units +Limit each numbered entry to one cohesive operational step. Combine only closely coupled micro-actions occurring at the same station. + +- **Do**: + 1. Heat the oil in a heavy-bottomed pot over medium-high heat. + 2. Add the diced onions and cook for 5 minutes, or until translucent. + 3. Stir in the minced garlic and cook for 1 minute until fragrant. +- **Don't**: + 1. Heat the oil, chop and cook the onions until translucent, then add garlic and cook for 1 minute before pouring in the stock. + +### E. Include Sensory Criteria and Measurable Targets +Pair time and temperature measurements with visual, tactile, or olfactory checkpoints. + +- **Do**: "Bake at 375 °F for 45 to 50 minutes, or until a cake tester inserted into the center comes out clean." +- **Do**: "Simmer over low heat for 20 minutes, or until the liquid has reduced by half." +- **Don't**: "Bake for a while until done." +- **Don't**: "Cook for 20 minutes." *(Lacks target consistency cue)* + +--- + +## 2. Formatting, Capitalization, and Punctuation + +| Element | Format Rule | Example | +|---|---|---| +| **Step Sentence** | Capitalize first word; end with a period. | `Transfer the dough to a clean, oiled bowl.` | +| **Section Headings** | Sentence case or Title case; must end with a colon `:`. | `For the dough:` / `To bake and finish:` | +| **Inline Prep Notes** | Wrapped in parentheses `(...)`; italicized in UI. | `(Note: Dough can be refrigerated for up to 48 hours before baking.)` | +| **Temperatures** | Number followed by degree symbol and scale (`°F`, `°C`). | `375 °F (190 °C)` | +| **Dimensions & Times** | Standard units with spaces; hyphenated when modifying nouns. | `1/2-inch dice` / `2 to 3 minutes` | +| **Critical Control Points (CCP)** | Prefix with `[CCP]` followed by regulatory threshold. | `[CCP] Hold hot at 135 °F (57 °C) or above.` | + +--- + +## 3. Sectioning Complex Multi-Stage Recipes + +When a recipe consists of more than 5 to 7 steps, organize the procedure into logical stages using section headers ending with a colon: + +```yaml +steps: + - id: step_01 + order: 1 + instruction: "For the dough:" + - id: step_02 + order: 2 + instruction: "In a stand mixer bowl, combine the flour, yeast, and salt." + - id: step_03 + order: 3 + instruction: "Add the warm water and mix on low speed for 6 minutes." + - id: step_04 + order: 4 + instruction: "To proof and shape:" + - id: step_05 + order: 5 + instruction: "Cover the bowl with plastic wrap and let rise for 1 hour at room temperature." + - id: step_06 + order: 6 + instruction: "Divide the dough into 12 equal rounds and place on a parchment-lined sheet pan." + - id: step_07 + order: 7 + instruction: "To bake:" + - id: step_08 + order: 8 + instruction: "Bake at 425 °F for 18 to 20 minutes, or until deep golden brown." +``` + +--- + +## 4. UI Reference Conventions (Help Docs & Application Copy) + +When writing help articles or in-app instructions describing user interactions with Formulation: + +- **Bold UI Names**: Always bold buttons, fields, tabs, and menu items (e.g. **Save**, **Prep Method**, **Total Yield**). +- **Use "Select"**: Use **Select** rather than *Click*, *Click on*, or *Tap*. +- **Use "Enter"**: Use **Enter** rather than *Type in* or *Input*. +- **Use "Go to"**: Use **Go to** for tab or page navigation (e.g. "Go to **Recipes** > **New Recipe**"). +- **Avoid UI Jargon**: Avoid referring to *dialog boxes*, *blades*, or *dropdown menus* unless essential for clarity. diff --git a/package-lock.json b/package-lock.json index 9af316e..fdfd5ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "dependencies": { "@astrojs/node": "^11.1.1", "@astrojs/preact": "6.0.2", + "@modelcontextprotocol/sdk": "^1.30.0", "astro": "7.2.1", "preact": "10.29.8", - "yaml": "2.9.0" + "yaml": "2.9.0", + "zod": "^4.4.3" }, "devDependencies": { "@astrojs/check": "0.9.4", @@ -1272,6 +1274,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1786,20 +1800,44 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, "engines": { - "node": "^22.20 || ^24.12 || >=25" + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } } }, "node_modules/@napi-rs/wasm-runtime": { @@ -2173,331 +2211,6 @@ } } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", - "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", - "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", - "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", - "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", - "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", - "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", - "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", - "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", - "cpu": [ - "arm" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", - "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", - "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", - "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", - "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", - "cpu": [ - "loong64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", - "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", - "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", - "cpu": [ - "ppc64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", - "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", - "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", - "cpu": [ - "riscv64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", - "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", - "cpu": [ - "s390x" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", - "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", - "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", - "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "openbsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", - "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "openharmony" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", - "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", - "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", - "cpu": [ - "ia32" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", - "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", - "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@shikijs/core": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", @@ -2882,11 +2595,23 @@ "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", "dev": true }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/ajv": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2912,6 +2637,23 @@ } } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ajv-i18n": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/ajv-i18n/-/ajv-i18n-4.2.0.tgz", @@ -3127,6 +2869,43 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3164,6 +2943,44 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -3386,6 +3203,28 @@ "node": ">= 18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3408,6 +3247,46 @@ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==" }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/crossws": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", @@ -3629,6 +3508,20 @@ "node": ">=4" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3668,11 +3561,41 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -3744,6 +3667,27 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3753,6 +3697,77 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3761,8 +3776,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", @@ -3781,7 +3795,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "dev": true, "funding": [ { "type": "github", @@ -3817,6 +3830,27 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/flattie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", @@ -3844,6 +3878,15 @@ "node": ">=20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -3865,6 +3908,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3882,6 +3934,43 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "5.0.0-beta.4", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", @@ -3901,6 +3990,18 @@ "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/h3": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", @@ -3917,6 +4018,30 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -4023,6 +4148,15 @@ "he": "bin/he" } }, + "node_modules/hono": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4061,11 +4195,45 @@ "url": "https://opencollective.com/express" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/iron-webcrypto": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", @@ -4108,6 +4276,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4148,8 +4337,13 @@ "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" }, "node_modules/json5": { "version": "2.2.3", @@ -4445,6 +4639,15 @@ "source-map-js": "^1.2.1" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -4470,6 +4673,31 @@ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -4613,6 +4841,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neotraverse": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", @@ -4679,6 +4916,27 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", @@ -4717,6 +4975,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -4788,12 +5055,40 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", "dev": true }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -4821,6 +5116,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -4913,6 +5217,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -4930,6 +5263,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4983,7 +5331,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -5041,52 +5388,28 @@ "@rolldown/binding-win32-x64-msvc": "1.2.3" } }, - "node_modules/rollup": { - "version": "4.62.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", - "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", - "optional": true, - "peer": true, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.62.4", - "@rollup/rollup-android-arm64": "4.62.4", - "@rollup/rollup-darwin-arm64": "4.62.4", - "@rollup/rollup-darwin-x64": "4.62.4", - "@rollup/rollup-freebsd-arm64": "4.62.4", - "@rollup/rollup-freebsd-x64": "4.62.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", - "@rollup/rollup-linux-arm-musleabihf": "4.62.4", - "@rollup/rollup-linux-arm64-gnu": "4.62.4", - "@rollup/rollup-linux-arm64-musl": "4.62.4", - "@rollup/rollup-linux-loong64-gnu": "4.62.4", - "@rollup/rollup-linux-loong64-musl": "4.62.4", - "@rollup/rollup-linux-ppc64-gnu": "4.62.4", - "@rollup/rollup-linux-ppc64-musl": "4.62.4", - "@rollup/rollup-linux-riscv64-gnu": "4.62.4", - "@rollup/rollup-linux-riscv64-musl": "4.62.4", - "@rollup/rollup-linux-s390x-gnu": "4.62.4", - "@rollup/rollup-linux-x64-gnu": "4.62.4", - "@rollup/rollup-linux-x64-musl": "4.62.4", - "@rollup/rollup-openbsd-x64": "4.62.4", - "@rollup/rollup-openharmony-arm64": "4.62.4", - "@rollup/rollup-win32-arm64-msvc": "4.62.4", - "@rollup/rollup-win32-ia32-msvc": "4.62.4", - "@rollup/rollup-win32-x64-gnu": "4.62.4", - "@rollup/rollup-win32-x64-msvc": "4.62.4", - "fsevents": "~2.3.2" + "node": ">= 18" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/satteri": { "version": "0.9.5", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", @@ -5150,6 +5473,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/server-destroy": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz", @@ -5221,6 +5563,27 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shiki": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", @@ -5239,6 +5602,78 @@ "node": ">=20" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5442,6 +5877,37 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "optional": true }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typesafe-path": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", @@ -5594,6 +6060,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unstorage": { "version": "1.17.5", "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", @@ -5752,6 +6227,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6274,6 +6758,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6290,6 +6789,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", @@ -6455,10 +6960,20 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 866431e..c35b4af 100644 --- a/package.json +++ b/package.json @@ -19,16 +19,19 @@ "start:readonly": "FORMULATION_READ_ONLY=true HOST=127.0.0.1 PORT=4399 node dist/app/server/entry.mjs", "preview:app": "astro preview --config astro.app.config.mjs --port 4322", "test": "vitest run", - "db:backup": "node scripts/db-backup.mjs", - "db:reset": "node scripts/db-sync.mjs --reset", - "db:import:yaml": "node scripts/db-sync.mjs --reset" + "db:backup": "node scripts/backup.mjs export", + "db:restore": "node scripts/backup.mjs import", + "db:validate": "node scripts/backup.mjs validate", + "mcp": "node scripts/mcp-server.mjs" }, "dependencies": { "@astrojs/node": "^11.1.1", "@astrojs/preact": "6.0.2", + "@modelcontextprotocol/sdk": "^1.30.0", "astro": "7.2.1", "preact": "10.29.8", - "yaml": "2.9.0" + "yaml": "2.9.0", + "zod": "^4.4.3" }, "devDependencies": { "@astrojs/check": "0.9.4", diff --git a/scripts/backup.mjs b/scripts/backup.mjs new file mode 100644 index 0000000..3513348 --- /dev/null +++ b/scripts/backup.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { DatabaseSync } from "node:sqlite"; + +const root = path.resolve(import.meta.dirname, ".."); +const databasePath = path.join(root, "var", "recipe-book.sqlite"); + +// Dynamically import compiled or source backup engine +import { exportDatabase } from "../src/lib/backup/export-database.ts"; +import { importDatabase } from "../src/lib/backup/import-database.ts"; +import { validateBackupBundle } from "../src/lib/backup/validate-backup.ts"; + +function printUsage() { + console.log(` +Formulation Database Backup & Restore Tool + +Usage: + node scripts/backup.mjs export [output-path.json] + node scripts/backup.mjs import [--replace | --merge] + node scripts/backup.mjs validate + +Commands: + export Extracts all 25 SQLite tables into a standardized JSON backup bundle. + import Restores or merges a backup bundle into the active SQLite database. + validate Checks a backup JSON file for schema integrity without modifying the database. + +Options: + --replace (Default for import) Atomically replaces existing database records. + --merge Upserts imported records without deleting unmentioned existing data. +`); +} + +async function main() { + const args = process.argv.slice(2); + const command = args[0]?.toLowerCase(); + + if (!command || command === "--help" || command === "-h" || command === "help") { + printUsage(); + process.exit(0); + } + + if (command === "export") { + if (!fs.existsSync(databasePath)) { + console.error(`Error: Database not found at ${databasePath}`); + process.exit(1); + } + const db = new DatabaseSync(databasePath); + try { + console.log("Exporting full Formulation database..."); + const bundle = exportDatabase(db); + const defaultName = `formulation-backup-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`; + const outputPath = args[1] ? path.resolve(process.cwd(), args[1]) : path.join(process.cwd(), defaultName); + + fs.writeFileSync(outputPath, JSON.stringify(bundle, null, 2), "utf8"); + console.log(`\n✅ Backup exported successfully to: ${outputPath}`); + console.log(` - Recipes: ${bundle.summary.recipes_count}`); + console.log(` - Ingredients: ${bundle.summary.ingredients_count}`); + console.log(` - Purchase Items: ${bundle.summary.purchase_items_count}`); + console.log(` - Collections: ${bundle.summary.collections_count}`); + console.log(` - Inventory Counts: ${bundle.summary.inventory_counts_count}`); + console.log(` - Total Entities: ${bundle.summary.total_records_count}`); + } finally { + db.close(); + } + return; + } + + if (command === "validate") { + const inputPath = args[1]; + if (!inputPath) { + console.error("Error: Please specify the path to a backup JSON file to validate."); + process.exit(1); + } + const resolvedPath = path.resolve(process.cwd(), inputPath); + if (!fs.existsSync(resolvedPath)) { + console.error(`Error: File not found: ${resolvedPath}`); + process.exit(1); + } + + const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + const result = validateBackupBundle(content); + + if (result.valid) { + console.log(`\n✅ Backup file '${inputPath}' is valid!`); + if (result.summary) { + console.log(` - Format Version: ${content.format_version}`); + console.log(` - Exported At: ${content.exported_at}`); + console.log(` - Recipes: ${result.summary.recipes_count}`); + console.log(` - Ingredients: ${result.summary.ingredients_count}`); + console.log(` - Purchase Items: ${result.summary.purchase_items_count}`); + console.log(` - Inventory Counts: ${result.summary.inventory_counts_count}`); + console.log(` - Total Entities: ${result.summary.total_records_count}`); + } + } else { + console.error(`\n❌ Backup validation failed:`); + for (const err of result.errors) console.error(` - ${err}`); + process.exit(1); + } + return; + } + + if (command === "import") { + const inputPath = args[1]; + if (!inputPath) { + console.error("Error: Please specify the path to a backup JSON file to import."); + process.exit(1); + } + const resolvedPath = path.resolve(process.cwd(), inputPath); + if (!fs.existsSync(resolvedPath)) { + console.error(`Error: File not found: ${resolvedPath}`); + process.exit(1); + } + + const mode = args.includes("--merge") ? "merge" : "replace"; + const content = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + + if (!fs.existsSync(databasePath)) { + console.error(`Error: Database not found at ${databasePath}`); + process.exit(1); + } + + const db = new DatabaseSync(databasePath); + try { + console.log(`Importing '${inputPath}' into database (mode: ${mode})...`); + const result = importDatabase(db, content, { mode, rebuildProjections: true }); + console.log(`\n✅ ${result.message}`); + } catch (err) { + console.error(`\n❌ Import failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } finally { + db.close(); + } + return; + } + + console.error(`Error: Unknown command '${command}'`); + printUsage(); + process.exit(1); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/scripts/db-sync.mjs b/scripts/db-sync.mjs index f93fe78..8341e09 100644 --- a/scripts/db-sync.mjs +++ b/scripts/db-sync.mjs @@ -8,8 +8,14 @@ import { writeSiteProjection } from "./lib/site-projection.mjs"; const root = path.resolve(import.meta.dirname, ".."); const databasePath = path.join(root, "var", "recipe-book.sqlite"); -if (!process.argv.includes("--reset")) throw new Error("Database initialization replaces the local database. Re-run with --reset."); -if (fs.existsSync(databasePath)) fs.rmSync(databasePath); + +console.error( + "❌ ERROR: Direct SQLite database resets from culinary YAML files are disabled.\n" + + "The SQLite database (var/recipe-book.sqlite) and its JSON backup snapshots (scripts/backup.mjs) are the canonical source of truth.\n" + + "To backup the database: npm run db:backup -- [backup.json]\n" + + "To restore from backup: npm run db:restore -- \n" +); +process.exit(1); fs.mkdirSync(path.dirname(databasePath), { recursive: true }); const db = new DatabaseSync(databasePath); db.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;"); @@ -22,7 +28,12 @@ const records = (directory) => { const run = (sql, values) => db.prepare(sql).run(...values); const json = (value) => JSON.stringify(value ?? []); -db.exec(fs.readFileSync(path.join(root, "migrations", "001_initial.sql"), "utf8")); +const migrationsDir = path.join(root, "migrations"); +for (const file of fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort()) { + try { + db.exec(fs.readFileSync(path.join(migrationsDir, file), "utf8")); + } catch {} +} db.exec("BEGIN IMMEDIATE"); try { @@ -61,7 +72,7 @@ try { JOIN prep_actions a ON a.id = p.action_id WHERE i.ingredient_id IS NOT NULL ON CONFLICT(ingredient_id, action_id) DO NOTHING`); for (const item of records("purchase_items")) { - run("INSERT INTO purchase_items VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]); + run("INSERT INTO purchase_items(id, ingredient_id, name, brand, supplier_id, supplier_sku, status, package_quantity, package_unit_id, units_per_case, usable_yield_factor, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)", [item.id, item.ingredient_id, item.name, item.brand ?? null, item.supplier_id ?? null, item.supplier_sku ?? null, item.status, item.package.quantity, item.package.unit_id, item.package.units_per_case ?? 1, item.package.usable_yield_factor ?? 1]); for (const price of item.prices) run("INSERT INTO price_observations VALUES (?, ?, ?, ?, ?)", [item.id, price.effective_at, price.currency, price.amount, json(price.source)]); } for (const mapping of records("source_mappings")) run("INSERT INTO source_mappings VALUES (?, ?, ?, ?, ?, ?, ?)", [mapping.id, mapping.subject.type, mapping.subject.id, mapping.mapping_type, mapping.status, json(mapping.source), mapping.nutrition_per_100g ? json(mapping.nutrition_per_100g) : null]); diff --git a/scripts/lib/site-projection.mjs b/scripts/lib/site-projection.mjs index 561b10e..a9264da 100644 --- a/scripts/lib/site-projection.mjs +++ b/scripts/lib/site-projection.mjs @@ -48,8 +48,6 @@ export function createSiteProjection(database) { export function writeSiteProjection(database, target=path.resolve(process.cwd(),"generated","site-projection.json")) { fs.mkdirSync(path.dirname(target),{recursive:true}); - const temporary=`${target}.tmp`; - fs.writeFileSync(temporary,`${JSON.stringify(createSiteProjection(database))}\n`); - fs.renameSync(temporary,target); + fs.writeFileSync(target,`${JSON.stringify(createSiteProjection(database))}\n`); return target; } diff --git a/scripts/lint-recipe-instructions.mjs b/scripts/lint-recipe-instructions.mjs new file mode 100644 index 0000000..d66adf7 --- /dev/null +++ b/scripts/lint-recipe-instructions.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import YAML from "yaml"; + +const root = path.resolve(import.meta.dirname, ".."); +const recipesDir = path.join(root, "culinary", "recipes"); + +const autofix = process.argv.includes("--fix") || process.argv.includes("--autofix"); + +const gerundMap = { + "mixing": "Mix", + "combining": "Combine", + "adding": "Add", + "whisking": "Whisk", + "stirring": "Stir", + "baking": "Bake", + "cooking": "Cook", + "heating": "Heat", + "pouring": "Pour", + "placing": "Place", + "cutting": "Cut", + "dicing": "Dice", + "chopping": "Chop", + "kneading": "Knead", + "rolling": "Roll", + "folding": "Fold", + "preheating": "Preheat", + "seasoning": "Season", + "simmering": "Simmer", + "boiling": "Boil", + "cooling": "Cool", + "refrigerating": "Refrigerate", + "freezing": "Freeze", + "storing": "Store", + "serving": "Serve", + "garnishing": "Garnish", +}; + +function standardizeInstruction(text) { + let clean = text.trim(); + if (!clean) return clean; + + // Check if it's an inline note: (Note: ...) or (...) + if (clean.startsWith("(") && clean.endsWith(")")) { + return clean; + } + + // Check if it's a section heading: ends with colon + if (clean.endsWith(":")) { + // Capitalize first character + clean = clean.charAt(0).toUpperCase() + clean.slice(1); + return clean; + } + + // Check for common gerund starts + const words = clean.split(/\s+/); + const firstWordLower = words[0].toLowerCase(); + if (gerundMap[firstWordLower]) { + words[0] = gerundMap[firstWordLower]; + clean = words.join(" "); + } + + // Capitalize first letter + clean = clean.charAt(0).toUpperCase() + clean.slice(1); + + // Replace trailing comma, semicolon, or dash with period + clean = clean.replace(/[,;\-\s]+$/, ""); + + // Ensure terminal punctuation if not ending with : or ) + if (!clean.endsWith(".") && !clean.endsWith("!") && !clean.endsWith("?") && !clean.endsWith(":") && !clean.endsWith(")")) { + clean += "."; + } + + return clean; +} + +function lintRecipeFile(filePath) { + const content = fs.readFileSync(filePath, "utf8"); + const data = YAML.parse(content); + if (!data || !Array.isArray(data.steps)) return { warnings: [], errors: [], changed: false }; + + const issues = []; + let changed = false; + + const newSteps = data.steps.map((step, index) => { + const original = step.instruction ?? ""; + const standardized = standardizeInstruction(original); + + if (original !== standardized) { + issues.push({ + stepOrder: step.order ?? index + 1, + original, + standardized, + }); + if (autofix) { + changed = true; + return { ...step, instruction: standardized }; + } + } + return step; + }); + + if (changed && autofix) { + data.steps = newSteps; + fs.writeFileSync(filePath, YAML.stringify(data, { indent: 2, lineWidth: 0 }), "utf8"); + } + + return { issues, changed }; +} + +function main() { + const files = fs.readdirSync(recipesDir).filter((f) => f.endsWith(".yaml")).sort(); + let totalIssues = 0; + let filesModified = 0; + + console.log(`Auditing ${files.length} recipe instruction files against Microsoft procedural guidelines...`); + + for (const file of files) { + const filePath = path.join(recipesDir, file); + const { issues, changed } = lintRecipeFile(filePath); + if (issues.length > 0) { + totalIssues += issues.length; + if (changed) filesModified++; + console.log(`\n📄 ${file} (${issues.length} issue${issues.length > 1 ? "s" : ""}):`); + for (const issue of issues) { + console.log(` Step ${issue.stepOrder}:`); + console.log(` - Current: "${issue.original}"`); + console.log(` + Standard: "${issue.standardized}"`); + } + } + } + + console.log("\n--------------------------------------------------"); + if (autofix) { + console.log(`✅ Standardized ${totalIssues} instructions across ${filesModified} recipe files.`); + } else { + console.log(`Found ${totalIssues} non-standard instructions.`); + if (totalIssues > 0) { + console.log("Run with --fix to apply automated standardization."); + } + } +} + +main(); diff --git a/scripts/mcp-server.mjs b/scripts/mcp-server.mjs new file mode 100644 index 0000000..52a5fee --- /dev/null +++ b/scripts/mcp-server.mjs @@ -0,0 +1,335 @@ +#!/usr/bin/env node +/** + * Formulation MCP Server — standalone stdio entry point. + * + * This script opens the SQLite database directly (bypassing Astro runtime) + * and starts the Model Context Protocol server on stdin/stdout. + * + * Usage: + * node scripts/mcp-server.mjs + * npm run mcp + */ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { createSiteProjection } from "./lib/site-projection.mjs"; + +// --------------------------------------------------------------------------- +// Database helpers (standalone, no Astro dependency) +// --------------------------------------------------------------------------- +const root = path.resolve(import.meta.dirname, ".."); +const databasePath = path.join(root, "var", "recipe-book.sqlite"); + +function openDb(readOnly = true) { + if (!fs.existsSync(databasePath)) throw new Error(`Database not found at ${databasePath}`); + const db = new DatabaseSync(databasePath, { readOnly }); + db.exec("PRAGMA foreign_keys = ON"); + return db; +} + +function loadCatalogs() { + const db = openDb(true); + try { + const projection = createSiteProjection(db); + const map = (values) => new Map(values.map((v) => [v.id, v])); + return { + ingredients: map(projection.ingredients), + recipes: map(projection.recipes), + units: map(projection.units), + equipment: map(projection.equipment), + prepActions: map(projection.prepActions), + purchaseItems: map(projection.purchaseItems), + sourceMappings: map(projection.sourceMappings), + }; + } finally { + db.close(); + } +} + +// --------------------------------------------------------------------------- +// Dynamic imports for domain modules (TypeScript, processed by Node's ESM) +// --------------------------------------------------------------------------- +const { calculateCost } = await import("../src/lib/costing.ts"); +const { calculateNutrition } = await import("../src/lib/nutrition.ts"); +const { convert, convertWithIngredientMeasures } = await import("../src/lib/measurement.ts"); +const { exportDatabase } = await import("../src/lib/backup/export-database.ts"); +const { titleCase } = await import("../src/lib/format.ts"); +const { getInventoryCountDetail, getInventoryCounts } = await import("../src/lib/repository/inventory-repository.ts"); +const { recipeQualityRows, restoreArchivedItems } = await import("../src/lib/database.ts"); + +// --------------------------------------------------------------------------- +// Tool implementations +// --------------------------------------------------------------------------- +function searchRecipes(args) { + const catalogs = loadCatalogs(); + const q = (args.query ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); + let results = [...catalogs.recipes.values()]; + + if (q) results = results.filter((r) => r.title.toLowerCase().includes(q) || r.id.toLowerCase().includes(q) || (r.summary ?? "").toLowerCase().includes(q)); + if (args.category) results = results.filter((r) => r.categories.includes(args.category)); + if (args.tag) results = results.filter((r) => (r.tags ?? []).includes(args.tag)); + + return results.slice(0, limit).map((r) => ({ + id: r.id, title: r.title, summary: r.summary ?? null, + categories: r.categories, tags: r.tags, + yield: { quantity: r.yield.amount.quantity, unit_id: r.yield.amount.unit_id, servings: r.yield.servings ?? null }, + component_count: r.components.length, + item_count: r.components.reduce((n, c) => n + c.items.length, 0), + step_count: r.steps.length, + })); +} + +function getRecipe(args) { + const catalogs = loadCatalogs(); + const recipe = catalogs.recipes.get(args.id); + if (!recipe) throw new Error(`Recipe not found: ${args.id}`); + + let sf = 1; + if (args.scale_factor > 0) sf = args.scale_factor; + else if (args.target_yield > 0 && recipe.yield.amount.quantity > 0) { + if (args.target_yield_unit && args.target_yield_unit !== recipe.yield.amount.unit_id) { + try { sf = convert(args.target_yield, args.target_yield_unit, recipe.yield.amount.unit_id, catalogs.units) / recipe.yield.amount.quantity; } catch { sf = args.target_yield / recipe.yield.amount.quantity; } + } else sf = args.target_yield / recipe.yield.amount.quantity; + } + + return { + id: recipe.id, title: recipe.title, summary: recipe.summary ?? null, + categories: recipe.categories, tags: recipe.tags, station: recipe.station ?? null, + yield: { quantity: recipe.yield.amount.quantity * sf, base_quantity: recipe.yield.amount.quantity, unit_id: recipe.yield.amount.unit_id, servings: recipe.yield.servings ? recipe.yield.servings * sf : null, basis: recipe.yield.basis ?? null }, + scale_factor: sf, scaling: recipe.scaling ?? null, + components: recipe.components.map((c) => ({ + id: c.id, name: c.name, notes: c.notes ?? [], + items: c.items.map((item) => { + const ref = item.reference; + const isSub = "recipe_id" in ref; + const sid = isSub ? ref.recipe_id : ref.ingredient_id; + const subject = isSub ? catalogs.recipes.get(sid) : catalogs.ingredients.get(sid); + return { + id: item.id, + ingredient_id: isSub ? undefined : sid, + subrecipe_id: isSub ? sid : undefined, + name: subject ? (isSub ? subject.title : titleCase(subject.name)) : sid, + is_subrecipe: isSub, + quantity: item.amount.quantity * sf, + base_quantity: item.amount.quantity, + unit_id: item.amount.unit_id, + percentage: item.percentage ?? null, + basis_member: item.basis_member ?? false, + optional: item.optional ?? false, + notes: item.notes ?? null, + prep: item.prep ?? [], + }; + }), + })), + steps: recipe.steps.map((s, i) => ({ id: s.id, order: i + 1, instruction: s.instruction, critical_control_point: s.critical_control_point ?? false, equipment_ids: s.equipment_ids ?? [] })), + equipment_ids: recipe.equipment_ids ?? [], + notes: recipe.notes ?? [], + shelf_life: recipe.shelf_life ?? null, + }; +} + +function calcCost(args) { + const catalogs = loadCatalogs(); + const recipe = catalogs.recipes.get(args.recipe_id); + if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); + return calculateCost(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, purchaseItems: catalogs.purchaseItems, prepActions: catalogs.prepActions }, args.currency ?? "USD"); +} + +function calcNutrition(args) { + const catalogs = loadCatalogs(); + const recipe = catalogs.recipes.get(args.recipe_id); + if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); + return calculateNutrition(recipe, { recipes: catalogs.recipes, ingredients: catalogs.ingredients, units: catalogs.units, mappings: catalogs.sourceMappings }); +} + +function searchIngredients(args) { + const catalogs = loadCatalogs(); + const q = (args.query ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); + let results = [...catalogs.ingredients.values()]; + + if (q) results = results.filter((ing) => ing.name.toLowerCase().includes(q) || ing.id.toLowerCase().includes(q) || (ing.aliases ?? []).some((a) => a.name.toLowerCase().includes(q))); + if (args.category) results = results.filter((ing) => ing.categories.includes(args.category)); + if (args.missing_cost) { + const priced = new Set([...catalogs.purchaseItems.values()].filter((pi) => pi.status === "active").map((pi) => pi.ingredient_id)); + results = results.filter((ing) => !priced.has(ing.id)); + } + + return results.slice(0, limit).map((ing) => ({ + id: ing.id, name: titleCase(ing.name), status: ing.status, categories: ing.categories, + alias_count: (ing.aliases ?? []).length, + has_nutrition: (ing.nutrition_mapping_ids ?? []).length > 0, + has_cost: [...catalogs.purchaseItems.values()].some((pi) => pi.ingredient_id === ing.id && pi.status === "active"), + })); +} + +function getIngredientDetail(args) { + const catalogs = loadCatalogs(); + const ing = catalogs.ingredients.get(args.id); + if (!ing) throw new Error(`Ingredient not found: ${args.id}`); + const purchases = [...catalogs.purchaseItems.values()].filter((pi) => pi.ingredient_id === args.id); + return { + id: ing.id, name: titleCase(ing.name), raw_name: ing.name, status: ing.status, + categories: ing.categories, tags: ing.tags ?? [], aliases: ing.aliases ?? [], + density_measurements: (ing.density_measurements ?? []).map((d) => ({ id: d.id, mass: d.mass, volume: d.volume, state: d.state ?? null })), + measure_conversions: (ing.measure_conversions ?? []).map((c) => ({ id: c.id, from: c.from, to: c.to, state: c.state ?? null })), + prep_actions: ing.prep_actions ?? [], + nutrition_mapping_ids: ing.nutrition_mapping_ids ?? [], + purchase_items: purchases.map((p) => ({ id: p.id, name: p.name, brand: p.brand ?? null, status: p.status, package_quantity: p.package.quantity, package_unit_id: p.package.unit_id, latest_price: p.prices.length > 0 ? p.prices[p.prices.length - 1].amount : null, currency: p.prices.length > 0 ? p.prices[p.prices.length - 1].currency : null })), + }; +} + +function convertUnits(args) { + const catalogs = loadCatalogs(); + const units = catalogs.units; + const fromUnit = units.get(args.from_unit); + const toUnit = units.get(args.to_unit); + if (!fromUnit) throw new Error(`Unknown unit: ${args.from_unit}`); + if (!toUnit) throw new Error(`Unknown unit: ${args.to_unit}`); + + let result, method; + if (fromUnit.dimension === toUnit.dimension) { + result = convert(args.quantity, args.from_unit, args.to_unit, units); + method = "dimension_conversion"; + } else if (args.ingredient_id) { + const ing = catalogs.ingredients.get(args.ingredient_id); + if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`); + const converted = convertWithIngredientMeasures({ quantity: args.quantity, unit_id: args.from_unit }, args.to_unit, ing, units); + result = converted.quantity; + method = "ingredient_measure_conversion"; + } else { + throw new Error(`Cannot convert ${fromUnit.dimension} to ${toUnit.dimension} without an ingredient_id for density lookup.`); + } + return { from: { quantity: args.quantity, unit_id: args.from_unit }, to: { quantity: result, unit_id: args.to_unit }, ingredient_id: args.ingredient_id ?? null, method }; +} + +function listUnits(args) { + const catalogs = loadCatalogs(); + let units = [...catalogs.units.values()]; + if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension); + if (args?.system) units = units.filter((u) => u.system === args.system); + return units; +} + +function listEquipment(args) { + const catalogs = loadCatalogs(); + let items = [...catalogs.equipment.values()]; + if (args?.category) items = items.filter((e) => e.category === args.category); + return items; +} + +function listPrepActions() { + const catalogs = loadCatalogs(); + return [...catalogs.prepActions.values()]; +} + +function listPurchaseItems(args) { + const db = openDb(true); + try { + let query = ` + SELECT p.id, p.ingredient_id, i.name as ingredient_name, p.name, p.brand, + p.supplier_id, p.supplier_sku, p.status, p.package_quantity, p.package_unit_id, + p.units_per_case, p.usable_yield_factor + FROM purchase_items p + JOIN ingredients i ON i.id = p.ingredient_id + WHERE p.deleted_at IS NULL + `; + const params = []; + if (args.ingredient_id) { query += " AND p.ingredient_id = ?"; params.push(args.ingredient_id); } + if (args.status) { query += " AND p.status = ?"; params.push(args.status); } + query += " ORDER BY p.name LIMIT ?"; + params.push(Math.min(Math.max(args.limit ?? 50, 1), 200)); + + const rows = db.prepare(query).all(...params); + const priceStmt = db.prepare("SELECT amount, currency, effective_at FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC LIMIT 1"); + return rows.map((r) => { + const p = priceStmt.get(r.id); + return { ...r, ingredient_name: titleCase(r.ingredient_name), latest_price: p?.amount ?? null, currency: p?.currency ?? null }; + }); + } finally { db.close(); } +} + +function getDatabaseStats() { + const db = openDb(true); + try { + const c = (t) => db.prepare(`SELECT count(*) as c FROM ${t}`).get().c; + return { recipes: c("recipes"), ingredients: c("ingredients"), units: c("units"), equipment: c("equipment"), purchase_items: c("purchase_items"), collections: c("collections"), inventory_counts: c("inventory_counts") }; + } finally { db.close(); } +} + +function listRecipeBooks() { + const db = openDb(true); + try { + return db.prepare( + `SELECT c.id, c.name, c.description, COUNT(cr.recipe_id) as recipe_count + FROM collections c LEFT JOIN collection_recipes cr ON cr.collection_id = c.id + WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.name` + ).all(); + } finally { db.close(); } +} + +function getRecipeBook(args) { + const db = openDb(true); + try { + const book = db.prepare("SELECT id, name, description FROM collections WHERE id = ? AND deleted_at IS NULL").get(args.id); + if (!book) throw new Error(`Recipe book not found: ${args.id}`); + const recipes = db.prepare( + `SELECT r.id, r.title, r.summary, cr.position + FROM collection_recipes cr JOIN recipes r ON r.id = cr.recipe_id + WHERE cr.collection_id = ? AND r.deleted_at IS NULL ORDER BY cr.position, r.title` + ).all(args.id); + return { ...book, recipes }; + } finally { db.close(); } +} + +function auditQuality() { + const db = openDb(true); + try { + const rows = recipeQualityRows(db); + const clean = rows.filter((r) => r.placeholder_steps === 0 && r.unpriced_items === 0 && r.yield_basis !== null).length; + return { summary: { total: rows.length, clean, needs_attention: rows.length - clean }, recipes: rows }; + } finally { db.close(); } +} + +// --------------------------------------------------------------------------- +// MCP Server setup +// --------------------------------------------------------------------------- +const server = new McpServer({ name: "formulation", version: "2.0.0" }); + +function wrap(fn) { + return async (args) => { + try { + const result = fn(args); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true }; + } + }; +} + +server.tool("search_recipes", "Search recipes by keyword, category, or tag.", { query: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchRecipes)); +server.tool("get_recipe", "Get full recipe formulation with optional scaling.", { id: z.string(), scale_factor: z.number().positive().optional(), target_yield: z.number().positive().optional(), target_yield_unit: z.string().optional() }, wrap(getRecipe)); +server.tool("audit_recipe_quality", "Run automated quality audit on all recipes.", {}, wrap(auditQuality)); +server.tool("calculate_recipe_cost", "Compute itemized cost breakdown for a recipe.", { recipe_id: z.string(), currency: z.string().default("USD") }, wrap(calcCost)); +server.tool("calculate_recipe_nutrition", "Calculate nutrition facts per 100g and per serving.", { recipe_id: z.string(), serving_size_g: z.number().positive().optional() }, wrap(calcNutrition)); +server.tool("search_ingredients", "Search ingredients with cost/nutrition status.", { query: z.string().optional(), category: z.string().optional(), missing_cost: z.boolean().optional(), limit: z.number().int().min(1).max(100).default(25) }, wrap(searchIngredients)); +server.tool("get_ingredient", "Get ingredient detail with density, equivalencies, and prices.", { id: z.string() }, wrap(getIngredientDetail)); +server.tool("list_purchase_items", "List purchase items with current prices.", { ingredient_id: z.string().optional(), status: z.string().optional(), limit: z.number().int().default(50) }, wrap(listPurchaseItems)); +server.tool("convert_units", "Convert culinary units using ingredient density data.", { ingredient_id: z.string().optional(), quantity: z.number().positive(), from_unit: z.string(), to_unit: z.string() }, wrap(convertUnits)); +server.tool("list_units", "List measurement units with conversion factors.", { dimension: z.string().optional(), system: z.string().optional() }, wrap(listUnits)); +server.tool("list_equipment", "List kitchen equipment.", { category: z.string().optional() }, wrap(listEquipment)); +server.tool("list_prep_actions", "List culinary prep actions.", {}, wrap(listPrepActions)); +server.tool("list_inventory_counts", "List inventory counting sessions.", { status: z.enum(["all", "open", "completed"]).default("all") }, wrap((args) => { const db = openDb(true); try { let counts = getInventoryCounts(db); if (args.status !== "all") counts = counts.filter((c) => c.status === args.status); return counts; } finally { db.close(); } })); +server.tool("get_inventory_count", "Get full inventory count sheet with items and valuations.", { id: z.string() }, wrap((args) => { const db = openDb(true); try { const d = getInventoryCountDetail(db, args.id); if (!d) throw new Error(`Count not found: ${args.id}`); return d; } finally { db.close(); } })); +server.tool("list_recipe_books", "List recipe books with recipe counts.", {}, wrap(listRecipeBooks)); +server.tool("get_recipe_book", "Get recipe book detail with included recipes.", { id: z.string() }, wrap(getRecipeBook)); +server.tool("export_database_backup", "Export complete JSON backup of all 25 tables.", {}, wrap(() => { const db = openDb(true); try { return exportDatabase(db); } finally { db.close(); } })); +server.tool("get_database_stats", "Get entity count statistics.", {}, wrap(getDatabaseStats)); + +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error("Formulation MCP Server running on stdio"); diff --git a/src/application/pages/api/app/backup/export.ts b/src/application/pages/api/app/backup/export.ts new file mode 100644 index 0000000..1e6a45c --- /dev/null +++ b/src/application/pages/api/app/backup/export.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { exportDatabase } from "../../../../../lib/backup"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) { + return Response.json( + { error: "Database is unavailable." }, + { status: 503 } + ); + } + + try { + const bundle = exportDatabase(database); + const dateStr = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const filename = `formulation-backup-${dateStr}.json`; + + return new Response(JSON.stringify(bundle, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Export failed." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/app/backup/import.ts b/src/application/pages/api/app/backup/import.ts new file mode 100644 index 0000000..446d512 --- /dev/null +++ b/src/application/pages/api/app/backup/import.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { importDatabase, type FormulationBackupBundle, type ImportMode } from "../../../../../lib/backup"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) { + return Response.json( + { error: "Database is unavailable." }, + { status: 503 } + ); + } + + try { + const url = new URL(request.url); + const mode = (url.searchParams.get("mode") ?? "replace") as ImportMode; + const bundle = (await request.json()) as FormulationBackupBundle; + + const result = importDatabase(database, bundle, { + mode: mode === "merge" ? "merge" : "replace", + rebuildProjections: true, + }); + + return Response.json(result, { status: 200 }); + } catch (error) { + return Response.json( + { + error: error instanceof Error ? error.message : "Import failed.", + }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/app/backup/validate.ts b/src/application/pages/api/app/backup/validate.ts new file mode 100644 index 0000000..9ab857c --- /dev/null +++ b/src/application/pages/api/app/backup/validate.ts @@ -0,0 +1,25 @@ +import type { APIRoute } from "astro"; +import { validateBackupBundle } from "../../../../../lib/backup"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + try { + const payload = await request.json(); + const result = validateBackupBundle(payload); + return Response.json(result, { status: result.valid ? 200 : 400 }); + } catch (error) { + return Response.json( + { + valid: false, + errors: [ + error instanceof Error + ? `Invalid JSON: ${error.message}` + : "Invalid JSON payload.", + ], + warnings: [], + }, + { status: 400 } + ); + } +}; diff --git a/src/application/pages/api/v1/archive/index.ts b/src/application/pages/api/v1/archive/index.ts new file mode 100644 index 0000000..bcaf142 --- /dev/null +++ b/src/application/pages/api/v1/archive/index.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const archived = tools.listArchivedItems(); + return Response.json({ success: true, data: archived }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list archived items." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/archive/restore.ts b/src/application/pages/api/v1/archive/restore.ts new file mode 100644 index 0000000..0451072 --- /dev/null +++ b/src/application/pages/api/v1/archive/restore.ts @@ -0,0 +1,31 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!Array.isArray(body.items) || body.items.length === 0) { + return Response.json( + { success: false, error: "Items array with { id, type } elements is required." }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.restoreArchived({ items: body.items }); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to restore items." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/collections/[id].ts b/src/application/pages/api/v1/collections/[id].ts new file mode 100644 index 0000000..3a9c9ca --- /dev/null +++ b/src/application/pages/api/v1/collections/[id].ts @@ -0,0 +1,73 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const book = tools.getRecipeBook({ id }); + return Response.json({ success: true, data: book }); + } catch (error) { + const status = error instanceof Error && error.message.includes("not found") ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get recipe book." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.saveRecipeBook(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update recipe book." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe book ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM collections WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Recipe book not found." }, { status: 404 }); + + database.prepare("UPDATE collections SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Recipe book '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive recipe book." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/collections/index.ts b/src/application/pages/api/v1/collections/index.ts new file mode 100644 index 0000000..f231221 --- /dev/null +++ b/src/application/pages/api/v1/collections/index.ts @@ -0,0 +1,46 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const books = tools.listRecipeBooks(); + return Response.json({ success: true, count: books.length, data: books }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list recipe books." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.name || !String(body.name).trim()) { + return Response.json({ success: false, error: "Recipe book name is required." }, { status: 400 }); + } + + const tools = createMcpTools(() => database); + const result = tools.saveRecipeBook(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create recipe book." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/convert.ts b/src/application/pages/api/v1/convert.ts new file mode 100644 index 0000000..d4d49ef --- /dev/null +++ b/src/application/pages/api/v1/convert.ts @@ -0,0 +1,36 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../lib/database"; +import { createMcpTools } from "../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.quantity || !body.from_unit || !body.to_unit) { + return Response.json( + { success: false, error: "Required fields: quantity, from_unit, to_unit" }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.convertUnits({ + ingredient_id: body.ingredient_id, + quantity: Number(body.quantity), + from_unit: body.from_unit, + to_unit: body.to_unit, + }); + return Response.json({ success: true, data: result }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Conversion failed." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/equipment/index.ts b/src/application/pages/api/v1/equipment/index.ts new file mode 100644 index 0000000..f7d0b8e --- /dev/null +++ b/src/application/pages/api/v1/equipment/index.ts @@ -0,0 +1,26 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const equipment = tools.listEquipment({ + category: url.searchParams.get("category") ?? undefined, + }); + return Response.json({ success: true, count: equipment.length, data: equipment }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list equipment." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/ingredients/[id].ts b/src/application/pages/api/v1/ingredients/[id].ts new file mode 100644 index 0000000..cea441f --- /dev/null +++ b/src/application/pages/api/v1/ingredients/[id].ts @@ -0,0 +1,72 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const ingredient = tools.getIngredient({ id }); + return Response.json({ success: true, data: ingredient }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get ingredient." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.saveIngredient(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update ingredient." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing ingredient ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const result = tools.deleteIngredient({ id }); + return Response.json(result); + } catch (error) { + const status = error instanceof Error && error.message.includes("Cannot archive") ? 409 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to delete ingredient." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/ingredients/index.ts b/src/application/pages/api/v1/ingredients/index.ts new file mode 100644 index 0000000..f4886dc --- /dev/null +++ b/src/application/pages/api/v1/ingredients/index.ts @@ -0,0 +1,52 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const ingredients = tools.searchIngredients({ + query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined, + category: url.searchParams.get("category") ?? undefined, + missing_cost: url.searchParams.get("missing_cost") === "true" ? true : undefined, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50, + }); + return Response.json({ success: true, count: ingredients.length, data: ingredients }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list ingredients." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.name || !String(body.name).trim()) { + return Response.json({ success: false, error: "Ingredient name is required." }, { status: 400 }); + } + + const tools = createMcpTools(() => database); + const result = tools.saveIngredient(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create ingredient." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/inventory/counts.ts b/src/application/pages/api/v1/inventory/counts.ts new file mode 100644 index 0000000..fc88b8b --- /dev/null +++ b/src/application/pages/api/v1/inventory/counts.ts @@ -0,0 +1,58 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const statusParam = url.searchParams.get("status"); + const status = statusParam === "open" || statusParam === "completed" ? statusParam : "all"; + + const tools = createMcpTools(() => database); + const counts = tools.listInventoryCounts({ status }); + return Response.json({ success: true, count: counts.length, data: counts }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list inventory counts." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.title || !body.counted_at) { + return Response.json( + { success: false, error: "Required fields: title, counted_at" }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.createInventoryCountSession({ + title: body.title, + counted_at: body.counted_at, + notes: body.notes, + prepopulate: body.prepopulate !== false, + }); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create inventory count." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/inventory/counts/[id].ts b/src/application/pages/api/v1/inventory/counts/[id].ts new file mode 100644 index 0000000..e87823a --- /dev/null +++ b/src/application/pages/api/v1/inventory/counts/[id].ts @@ -0,0 +1,77 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const count = tools.getInventoryCount({ id }); + return Response.json({ success: true, data: count }); + } catch (error) { + const status = error instanceof Error && error.message.includes("not found") ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get inventory count." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + const tools = createMcpTools(() => database); + const result = tools.updateInventoryCount({ + count_id: id, + status: body.status, + notes: body.notes, + items: body.items, + }); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update inventory count." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing count ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM inventory_counts WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Inventory count not found." }, { status: 404 }); + + database.prepare("UPDATE inventory_counts SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Inventory count '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive inventory count." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/prep-actions/index.ts b/src/application/pages/api/v1/prep-actions/index.ts new file mode 100644 index 0000000..55f7d35 --- /dev/null +++ b/src/application/pages/api/v1/prep-actions/index.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const actions = tools.listPrepActions(); + return Response.json({ success: true, count: actions.length, data: actions }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list prep actions." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/purchases/[id]/index.ts b/src/application/pages/api/v1/purchases/[id]/index.ts new file mode 100644 index 0000000..4bca43e --- /dev/null +++ b/src/application/pages/api/v1/purchases/[id]/index.ts @@ -0,0 +1,73 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const item = tools.getPurchaseItem({ id }); + return Response.json({ success: true, data: item }); + } catch (error) { + const status = error instanceof Error && error.message.includes("not found") ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get purchase item." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.savePurchaseItem(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update purchase item." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM purchase_items WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Purchase item not found." }, { status: 404 }); + + database.prepare("UPDATE purchase_items SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Purchase item '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive purchase item." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/purchases/[id]/prices.ts b/src/application/pages/api/v1/purchases/[id]/prices.ts new file mode 100644 index 0000000..a9ec617 --- /dev/null +++ b/src/application/pages/api/v1/purchases/[id]/prices.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing purchase item ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (typeof body.amount !== "number" || body.amount <= 0) { + return Response.json( + { success: false, error: "A positive price amount is required." }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.recordPriceObservation({ + purchase_item_id: id, + amount: body.amount, + currency: body.currency, + effective_at: body.effective_at, + }); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to record price observation." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/purchases/index.ts b/src/application/pages/api/v1/purchases/index.ts new file mode 100644 index 0000000..75c851b --- /dev/null +++ b/src/application/pages/api/v1/purchases/index.ts @@ -0,0 +1,54 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const items = tools.listPurchaseItems({ + ingredient_id: url.searchParams.get("ingredient_id") ?? undefined, + status: url.searchParams.get("status") ?? undefined, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50, + }); + return Response.json({ success: true, count: items.length, data: items }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list purchase items." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + if (!body.ingredient_id || !body.name || !body.package_quantity || !body.package_unit_id) { + return Response.json( + { success: false, error: "Required fields: ingredient_id, name, package_quantity, package_unit_id" }, + { status: 400 } + ); + } + + const tools = createMcpTools(() => database); + const result = tools.savePurchaseItem(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create purchase item." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/cost.ts b/src/application/pages/api/v1/recipes/[id]/cost.ts new file mode 100644 index 0000000..b8c497d --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/cost.ts @@ -0,0 +1,29 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const currency = url.searchParams.get("currency") ?? "USD"; + const tools = createMcpTools(() => database); + const cost = tools.calculateRecipeCost({ recipe_id: id, currency }); + return Response.json({ success: true, data: cost }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Cost calculation failed." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/index.ts b/src/application/pages/api/v1/recipes/[id]/index.ts new file mode 100644 index 0000000..810a429 --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/index.ts @@ -0,0 +1,79 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const recipe = tools.getRecipe({ + id, + scale_factor: url.searchParams.get("scale") ? Number(url.searchParams.get("scale")) : undefined, + target_yield: url.searchParams.get("yield") ? Number(url.searchParams.get("yield")) : undefined, + target_yield_unit: url.searchParams.get("yield_unit") ?? undefined, + }); + return Response.json({ success: true, data: recipe }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to get recipe." }, + { status } + ); + } finally { + database.close(); + } +}; + +export const PUT: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json(); + body.id = id; + const tools = createMcpTools(() => database); + const result = tools.saveRecipe(body); + return Response.json(result); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to update recipe." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; + +export const DELETE: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: false }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const existing = database.prepare("SELECT 1 FROM recipes WHERE id = ? AND deleted_at IS NULL").get(id); + if (!existing) return Response.json({ success: false, error: "Recipe not found." }, { status: 404 }); + + database.prepare("UPDATE recipes SET deleted_at = datetime('now') WHERE id = ?").run(id); + return Response.json({ success: true, message: `Recipe '${id}' archived.` }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to archive recipe." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/nutrition.ts b/src/application/pages/api/v1/recipes/[id]/nutrition.ts new file mode 100644 index 0000000..cd5b920 --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/nutrition.ts @@ -0,0 +1,27 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ params }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const nutrition = tools.calculateRecipeNutrition({ recipe_id: id }); + return Response.json({ success: true, data: nutrition }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Nutrition calculation failed." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/[id]/scale.ts b/src/application/pages/api/v1/recipes/[id]/scale.ts new file mode 100644 index 0000000..b04cd64 --- /dev/null +++ b/src/application/pages/api/v1/recipes/[id]/scale.ts @@ -0,0 +1,33 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../../lib/database"; +import { createMcpTools } from "../../../../../../mcp/tools"; + +export const prerender = false; + +export const POST: APIRoute = async ({ params, request }) => { + const id = params.id; + if (!id) return Response.json({ success: false, error: "Missing recipe ID." }, { status: 400 }); + + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const body = await request.json().catch(() => ({})); + const tools = createMcpTools(() => database); + const recipe = tools.getRecipe({ + id, + scale_factor: body.scale_factor, + target_yield: body.target_yield, + target_yield_unit: body.target_yield_unit, + }); + return Response.json({ success: true, data: recipe }); + } catch (error) { + const status = (error instanceof Error && error.message.includes("not found")) ? 404 : 500; + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Scaling failed." }, + { status } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/index.ts b/src/application/pages/api/v1/recipes/index.ts new file mode 100644 index 0000000..bde8e8e --- /dev/null +++ b/src/application/pages/api/v1/recipes/index.ts @@ -0,0 +1,53 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) { + return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + } + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const recipes = tools.searchRecipes({ + query: url.searchParams.get("q") ?? url.searchParams.get("query") ?? undefined, + category: url.searchParams.get("category") ?? undefined, + tag: url.searchParams.get("tag") ?? undefined, + limit: url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 50, + }); + + return Response.json({ success: true, count: recipes.length, data: recipes }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list recipes." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; + +export const POST: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: false }); + if (!database) { + return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + } + + try { + const body = await request.json(); + const tools = createMcpTools(() => database); + const result = tools.saveRecipe(body); + return Response.json(result, { status: 201 }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to create recipe." }, + { status: 400 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/recipes/quality.ts b/src/application/pages/api/v1/recipes/quality.ts new file mode 100644 index 0000000..94797ec --- /dev/null +++ b/src/application/pages/api/v1/recipes/quality.ts @@ -0,0 +1,23 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async () => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const tools = createMcpTools(() => database); + const audit = tools.auditRecipeQuality(); + return Response.json({ success: true, data: audit }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to run quality audit." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/api/v1/units/index.ts b/src/application/pages/api/v1/units/index.ts new file mode 100644 index 0000000..905603d --- /dev/null +++ b/src/application/pages/api/v1/units/index.ts @@ -0,0 +1,27 @@ +import type { APIRoute } from "astro"; +import { openDatabase } from "../../../../../lib/database"; +import { createMcpTools } from "../../../../../mcp/tools"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const database = openDatabase({ readOnly: true }); + if (!database) return Response.json({ success: false, error: "Database unavailable." }, { status: 503 }); + + try { + const url = new URL(request.url); + const tools = createMcpTools(() => database); + const units = tools.listUnits({ + dimension: url.searchParams.get("dimension") ?? undefined, + system: url.searchParams.get("system") ?? undefined, + }); + return Response.json({ success: true, count: units.length, data: units }); + } catch (error) { + return Response.json( + { success: false, error: error instanceof Error ? error.message : "Failed to list units." }, + { status: 500 } + ); + } finally { + database.close(); + } +}; diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index 6197024..b9680ac 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -109,7 +109,10 @@ const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/in {tabs.map((tab)=>{tab.label}{tab.count})} {type==="ingredient"&&
Filter{filtering?` · ${filteredIngredients.length}`:""}
Attention reasons
{filtering&&Clear}
} {type==="recipe"&&
Filter{filtering?` · ${filteredRecipes.length}`:""}
Attention reasons
{filtering&&Clear}
} - {!readOnlyMode&&Archive} + {!readOnlyMode&&<> + Archive + Data Management + } {query?

{searchResults.length} {searchResults.length===1?"result":"results"} for “{query}”{filteringSearchTypes&&` · ${selectedSearchTypes.length} item ${selectedSearchTypes.length===1?"type":"types"}`}

{searchRows.length?:
No items of the selected types match this search.
}
:<> {type==="ingredient"&&} diff --git a/src/application/pages/app/settings/index.astro b/src/application/pages/app/settings/index.astro new file mode 100644 index 0000000..02cb346 --- /dev/null +++ b/src/application/pages/app/settings/index.astro @@ -0,0 +1,684 @@ +--- +export const prerender = false; +import BaseLayout from "../../../../layouts/BaseLayout.astro"; +import DetailUtility from "../../../../components/DetailUtility.astro"; +import { openDatabase } from "../../../../lib/database"; +import { exportDatabase } from "../../../../lib/backup"; +import { readOnlyMode } from "../../../../lib/runtime"; + +const database = openDatabase({ readOnly: true }); +if (!database) return Astro.redirect("/app/", 303); + +let summary = { + recipes_count: 0, + ingredients_count: 0, + purchase_items_count: 0, + collections_count: 0, + inventory_counts_count: 0, + inventory_locations_count: 0, + total_records_count: 0, +}; + +try { + const bundle = exportDatabase(database); + summary = bundle.summary; +} catch (err) { + console.error("Failed to compute database summary:", err); +} finally { + database.close(); +} +--- + + +
+ + +
+
+
+

Data Management

+

+ Export complete database archives or restore backups across all 25 tables with full referential integrity. +

+
+
+ +
+ +
+
+
+ + + +
+
+

Active Database Status

+ SQLite local storage ({summary.total_records_count} total entities) +
+
+ +
+
+ Recipes + {summary.recipes_count} +
+
+ Ingredients + {summary.ingredients_count} +
+
+ Purchase Items + {summary.purchase_items_count} +
+
+ Recipe Books + {summary.collections_count} +
+
+ Inventory Counts + {summary.inventory_counts_count} +
+
+ Storage Locations + {summary.inventory_locations_count} +
+
+
+ + +
+
+
+ + + +
+
+

Export Database Backup

+ Download full-fidelity JSON archive +
+
+ +

+ Generates a portable backup bundle containing 100% of your formulations, ingredients, purchasing catalogs, cost histories, and inventory count sessions. +

+ + +
+ + +
+
+
+ + + +
+
+

Import & Restore Backup

+ Restore database from backup JSON +
+
+ + {readOnlyMode ? ( +
+ Database restoration is disabled in read-only demonstration mode. +
+ ) : ( +
+
+ + + + +

Select or drag a Formulation backup (.json) file

+ +
+ + + + + + +
+ )} +
+
+
+
+
+ + + + diff --git a/src/components/DetailUtility.astro b/src/components/DetailUtility.astro index 5761b27..6839ff9 100644 --- a/src/components/DetailUtility.astro +++ b/src/components/DetailUtility.astro @@ -27,6 +27,9 @@ if (pathname.startsWith("/app/inventory/")) { } else if (pathname.startsWith("/app/archive")) { inferredLabel = "Archive"; inferredHref = "/app/"; +} else if (pathname.startsWith("/app/settings")) { + inferredLabel = "Data Management"; + inferredHref = "/app/settings/"; } else if (pathname.startsWith("/tools/purchasing-review")) { inferredLabel = "Purchasing Review"; inferredHref = "/app/"; diff --git a/src/lib/backup/backup.test.ts b/src/lib/backup/backup.test.ts new file mode 100644 index 0000000..838b35b --- /dev/null +++ b/src/lib/backup/backup.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { DatabaseSync } from "node:sqlite"; +import fs from "node:fs"; +import path from "node:path"; +import { exportDatabase } from "./export-database"; +import { importDatabase } from "./import-database"; +import { validateBackupBundle } from "./validate-backup"; +import type { FormulationBackupBundle } from "./types"; + +function createTestDatabase(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + const root = path.resolve(__dirname, "../../.."); + const migrationsDir = path.join(root, "migrations"); + const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort(); + for (const file of files) { + const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8"); + db.exec(sql); + } + return db; +} + +describe("Database Backup & Restore System", () => { + let db: DatabaseSync; + + beforeEach(() => { + db = createTestDatabase(); + + // Populate baseline sample data + db.exec(` + INSERT INTO units (id, name, symbol, dimension, system) VALUES + ('gram', 'Gram', 'g', 'mass', 'metric'), + ('kilogram', 'Kilogram', 'kg', 'mass', 'metric'), + ('each', 'Each', 'ea', 'count', 'customary'); + + INSERT INTO equipment (id, name, category, notes) VALUES + ('whisk', 'Whisk', 'hand_tool', 'Stainless steel'); + + INSERT INTO prep_actions (id, name, action_type, default_yield_factor) VALUES + ('dice', 'Dice', 'cut', 0.95); + + INSERT INTO ingredients (id, schema_version, name, status, categories_json, tags_json, source_json) VALUES + ('flour', 2, 'Flour', 'active', '["dry"]', '["baking"]', '{}'), + ('sugar', 2, 'Sugar', 'active', '["dry"]', '["sweet"]', '{}'); + + INSERT INTO ingredient_aliases (ingredient_id, name, kind) VALUES + ('flour', 'All Purpose Flour', 'common'); + + INSERT INTO ingredient_prep_actions (ingredient_id, action_id, yield_factor) VALUES + ('flour', 'dice', 0.95); + + INSERT INTO ingredient_measure_conversions (ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, source_json) VALUES + ('flour', 'conv_1', 1, 'each', 120, 'gram', '{}'); + + INSERT INTO recipes (id, schema_version, save_version, title, yield_quantity, yield_unit_id, source_json) VALUES + ('cake', 2, 1, 'Simple Cake', 500, 'gram', '{}'); + + INSERT INTO recipe_components (recipe_id, id, position, name) VALUES + ('cake', 'main', 1, 'Main'); + + INSERT INTO recipe_items (recipe_id, component_id, id, position, ingredient_id, quantity, unit_id) VALUES + ('cake', 'main', 'item_1', 1, 'flour', 300, 'gram'), + ('cake', 'main', 'item_2', 2, 'sugar', 200, 'gram'); + + INSERT INTO item_prep_actions (recipe_id, item_id, position, action_id, yield_factor) VALUES + ('cake', 'item_1', 1, 'dice', 1.0); + + INSERT INTO recipe_steps (recipe_id, id, position, instruction) VALUES + ('cake', 'step_1', 1, 'Mix dry ingredients together.'); + + INSERT INTO purchase_items (id, ingredient_id, name, status, package_quantity, package_unit_id) VALUES + ('pi_flour_bag', 'flour', '50lb Flour Bag', 'active', 50, 'gram'); + + INSERT INTO price_observations (purchase_item_id, effective_at, currency, amount, source_json) VALUES + ('pi_flour_bag', '2026-08-01', 'USD', 24.50, '{}'); + + INSERT INTO collections (id, name, source_json) VALUES + ('bakery_menu', 'Bakery Menu', '{}'); + + INSERT INTO collection_recipes (collection_id, recipe_id, position) VALUES + ('bakery_menu', 'cake', 1); + + INSERT INTO inventory_locations (id, name, position) VALUES + ('loc_dry', 'Dry Storage', 1); + + INSERT INTO inventory_counts (id, title, counted_at, created_at) VALUES + ('count_aug', 'August Inventory', '2026-08-01', '2026-08-01'); + + INSERT INTO inventory_count_items (count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES + ('count_aug', 'loc_dry', 'flour', 10, 'gram', 0.5, 5.0); + `); + }); + + it("exports all tables into a valid FormulationBackupBundle", () => { + const bundle = exportDatabase(db); + + expect(bundle.format_version).toBe("1.0.0"); + expect(bundle.app_version).toBe("2.0.0"); + expect(bundle.summary.units_count).toBe(3); + expect(bundle.summary.ingredients_count).toBe(2); + expect(bundle.summary.recipes_count).toBe(1); + expect(bundle.summary.purchase_items_count).toBe(1); + expect(bundle.summary.collections_count).toBe(1); + expect(bundle.summary.inventory_locations_count).toBe(6); // 5 from baseline seed + 1 custom + expect(bundle.summary.inventory_counts_count).toBe(1); + + expect(bundle.data.recipes[0].items.length).toBe(2); + expect(bundle.data.recipes[0].items[0].prep_actions.length).toBe(1); + expect(bundle.data.ingredients[0].aliases.length).toBe(1); + expect(bundle.data.purchase_items[0].prices.length).toBe(1); + expect(bundle.data.inventory_counts[0].items.length).toBe(1); + }); + + it("validates backup bundle structure correctly", () => { + const bundle = exportDatabase(db); + const result = validateBackupBundle(bundle); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + + const invalid = validateBackupBundle({ format_version: "2.0.0", data: {} }); + expect(invalid.valid).toBe(false); + expect(invalid.errors.length).toBeGreaterThan(0); + }); + + it("performs full roundtrip export -> wipe -> import without data loss", () => { + const originalBundle = exportDatabase(db); + + const targetDb = createTestDatabase(); + // Wipe target db completely + targetDb.exec("DELETE FROM units;"); + + const result = importDatabase(targetDb, originalBundle, { mode: "replace", rebuildProjections: false }); + expect(result.success).toBe(true); + expect(result.summary.recipes_count).toBe(1); + + const restoredBundle = exportDatabase(targetDb); + expect(restoredBundle.summary.units_count).toBe(originalBundle.summary.units_count); + expect(restoredBundle.summary.ingredients_count).toBe(originalBundle.summary.ingredients_count); + expect(restoredBundle.summary.recipes_count).toBe(originalBundle.summary.recipes_count); + expect(restoredBundle.data.recipes[0].title).toBe("Simple Cake"); + expect(restoredBundle.data.ingredients[0].aliases[0].name).toBe("All Purpose Flour"); + expect(restoredBundle.data.inventory_counts[0].items[0].quantity).toBe(10); + }); + + it("supports merge mode without wiping existing unmentioned records", () => { + const targetDb = createTestDatabase(); + targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('meter', 'Meter', 'm', 'length', 'metric');"); + + const bundle = exportDatabase(db); + const result = importDatabase(targetDb, bundle, { mode: "merge", rebuildProjections: false }); + expect(result.success).toBe(true); + + const units = targetDb.prepare("SELECT id FROM units ORDER BY id").all() as Array<{ id: string }>; + expect(units.some((u) => u.id === "meter")).toBe(true); + expect(units.some((u) => u.id === "gram")).toBe(true); + }); + + it("rolls back transaction atomically if an error occurs during import", () => { + const targetDb = createTestDatabase(); + targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('gram', 'Gram', 'g', 'mass', 'metric');"); + + const badBundle: FormulationBackupBundle = { + $schema: "https://formulation.app/schemas/backup-v1.json", + format_version: "1.0.0", + app_version: "2.0.0", + exported_at: new Date().toISOString(), + summary: { + units_count: 1, equipment_count: 0, prep_actions_count: 0, + ingredients_count: 1, recipes_count: 0, purchase_items_count: 0, + price_observations_count: 0, source_mappings_count: 0, collections_count: 0, + inventory_locations_count: 0, inventory_counts_count: 0, total_records_count: 2 + }, + data: { + units: [{ id: "gram", name: "Gram", symbol: "g", dimension: "mass", system: "metric", base_unit_id: null, factor: null, offset: null }], + equipment: [], + prep_actions: [], + ingredients: [{ + id: "flour", schema_version: 2, name: "Flour", status: "active", + categories_json: "INVALID JSON SYNTAX", tags_json: "[]", description: null, source_json: "{}", deleted_at: null, + aliases: [], prep_actions: [], measure_conversions: [], density_measurements: [] + }], + recipes: [{ + id: "bad_recipe", schema_version: 2, save_version: 1, title: "Bad", summary: null, + categories_json: "[]", tags_json: "[]", yield_quantity: null as any, yield_unit_id: "gram", + yield_servings: null, yield_basis: null, scaling_mode: null, scaling_basis_id: null, + scaling_basis_quantity: null, scaling_basis_unit_id: null, notes_json: "[]", + source_json: "{}", auto_yield: 0, station: null, deleted_at: null, cover_media_url: null, + equipment_ids: [], components: [], items: [], steps: [], measure_conversions: [], media: [] + }], + purchase_items: [], + source_mappings: [], + collections: [], + inventory_locations: [], + inventory_counts: [], + } + }; + + // Attempt importing badBundle which should fail validation/execution + expect(() => { + importDatabase(targetDb, badBundle, { mode: "replace", rebuildProjections: false }); + }).toThrow(); + + // Verify existing units record remains intact + const units = targetDb.prepare("SELECT id FROM units").all() as Array<{ id: string }>; + expect(units.length).toBe(1); + expect(units[0].id).toBe("gram"); + }); +}); diff --git a/src/lib/backup/export-database.ts b/src/lib/backup/export-database.ts new file mode 100644 index 0000000..dbf3c7a --- /dev/null +++ b/src/lib/backup/export-database.ts @@ -0,0 +1,284 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { + FormulationBackupBundle, + UnitBackupRecord, + EquipmentBackupRecord, + PrepActionBackupRecord, + IngredientBackupRecord, + RecipeBackupRecord, + PurchaseItemBackupRecord, + SourceMappingBackupRecord, + CollectionBackupRecord, + InventoryLocationBackupRecord, + InventoryCountBackupRecord, + BackupSummary, +} from "./types"; + +export function exportDatabase(database: DatabaseSync): FormulationBackupBundle { + // 1. Units + const units = database + .prepare("SELECT * FROM units ORDER BY dimension, id") + .all() as unknown as UnitBackupRecord[]; + + // 2. Equipment + const equipment = database + .prepare("SELECT * FROM equipment ORDER BY id") + .all() as unknown as EquipmentBackupRecord[]; + + // 3. Prep Actions + const prepActions = database + .prepare("SELECT * FROM prep_actions ORDER BY id") + .all() as unknown as PrepActionBackupRecord[]; + + // 4. Ingredients & sub-tables + const aliasStmt = database.prepare( + "SELECT name, kind FROM ingredient_aliases WHERE ingredient_id = ? ORDER BY name" + ); + const ingredientPrepStmt = database.prepare( + "SELECT action_id, yield_factor, notes FROM ingredient_prep_actions WHERE ingredient_id = ? ORDER BY action_id" + ); + const ingredientConvStmt = database.prepare( + "SELECT id, from_quantity, from_unit_id, to_quantity, to_unit_id, state, source_json FROM ingredient_measure_conversions WHERE ingredient_id = ? ORDER BY id" + ); + const ingredientDensityStmt = database.prepare( + "SELECT id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, temperature_c, state, source_json FROM ingredient_density_measurements WHERE ingredient_id = ? ORDER BY id" + ); + + const rawIngredients = database + .prepare("SELECT * FROM ingredients ORDER BY id") + .all() as unknown as Array<{ + id: string; + schema_version: number; + name: string; + status: string; + categories_json: string; + tags_json: string; + description: string | null; + source_json: string; + deleted_at: string | null; + }>; + + const ingredients: IngredientBackupRecord[] = rawIngredients.map((ing) => ({ + ...ing, + aliases: aliasStmt.all(ing.id) as any[], + prep_actions: ingredientPrepStmt.all(ing.id) as any[], + measure_conversions: ingredientConvStmt.all(ing.id) as any[], + density_measurements: ingredientDensityStmt.all(ing.id) as any[], + })); + + // 5. Recipes & sub-tables + const componentStmt = database.prepare( + "SELECT id, position, name, notes_json FROM recipe_components WHERE recipe_id = ? ORDER BY position" + ); + const itemStmt = database.prepare( + "SELECT component_id, id, position, ingredient_id, subrecipe_id, quantity, unit_id, percentage, basis_member, optional, notes, nutrition_retention_factor FROM recipe_items WHERE recipe_id = ? ORDER BY component_id, position" + ); + const itemPrepStmt = database.prepare( + "SELECT position, action_id, yield_factor, notes FROM item_prep_actions WHERE recipe_id = ? AND item_id = ? ORDER BY position" + ); + const stepStmt = database.prepare( + "SELECT id, position, instruction, critical_control_point FROM recipe_steps WHERE recipe_id = ? ORDER BY position" + ); + const stepEquipmentStmt = database.prepare( + "SELECT equipment_id FROM step_equipment WHERE recipe_id = ? AND step_id = ? ORDER BY equipment_id" + ); + const recipeEquipmentStmt = database.prepare( + "SELECT equipment_id FROM recipe_equipment WHERE recipe_id = ? ORDER BY equipment_id" + ); + const recipeConvStmt = database.prepare( + "SELECT id, from_quantity, from_unit_id, to_quantity, to_unit_id, notes, source_json FROM recipe_measure_conversions WHERE recipe_id = ? ORDER BY id" + ); + const recipeMediaStmt = database.prepare( + "SELECT id, step_id, media_type, url, caption, position FROM recipe_media WHERE recipe_id = ? ORDER BY position" + ); + + const rawRecipes = database + .prepare("SELECT * FROM recipes ORDER BY id") + .all() as unknown as Array<{ + id: string; + schema_version: number; + save_version: number; + title: string; + summary: string | null; + categories_json: string; + tags_json: string; + yield_quantity: number; + yield_unit_id: string; + yield_servings: number | null; + yield_basis: string | null; + scaling_mode: string | null; + scaling_basis_id: string | null; + scaling_basis_quantity: number | null; + scaling_basis_unit_id: string | null; + notes_json: string; + source_json: string; + auto_yield: number; + station: string | null; + deleted_at: string | null; + cover_media_url: string | null; + }>; + + const recipes: RecipeBackupRecord[] = rawRecipes.map((rec) => { + const rawItems = itemStmt.all(rec.id) as any[]; + const items = rawItems.map((item) => ({ + ...item, + prep_actions: itemPrepStmt.all(rec.id, item.id) as any[], + })); + + const rawSteps = stepStmt.all(rec.id) as any[]; + const steps = rawSteps.map((step) => ({ + ...step, + equipment_ids: (stepEquipmentStmt.all(rec.id, step.id) as any[]).map( + (r) => r.equipment_id + ), + })); + + const equipment_ids = (recipeEquipmentStmt.all(rec.id) as any[]).map( + (r) => r.equipment_id + ); + + return { + ...rec, + equipment_ids, + components: componentStmt.all(rec.id) as any[], + items, + steps, + measure_conversions: recipeConvStmt.all(rec.id) as any[], + media: recipeMediaStmt.all(rec.id) as any[], + }; + }); + + // 6. Purchase items & price observations + const priceStmt = database.prepare( + "SELECT effective_at, currency, amount, source_json FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC" + ); + const rawPurchaseItems = database + .prepare("SELECT * FROM purchase_items ORDER BY id") + .all() as unknown as Array<{ + id: string; + ingredient_id: string; + name: string; + brand: string | null; + supplier_id: string | null; + supplier_sku: string | null; + status: string; + package_quantity: number; + package_unit_id: string; + units_per_case: number; + usable_yield_factor: number; + deleted_at: string | null; + }>; + + let totalPricesCount = 0; + const purchase_items: PurchaseItemBackupRecord[] = rawPurchaseItems.map( + (pi) => { + const prices = priceStmt.all(pi.id) as any[]; + totalPricesCount += prices.length; + return { + ...pi, + prices, + }; + } + ); + + // 7. Source mappings + const source_mappings = database + .prepare("SELECT * FROM source_mappings ORDER BY id") + .all() as unknown as SourceMappingBackupRecord[]; + + // 8. Collections & collection recipes + const collRecipeStmt = database.prepare( + "SELECT recipe_id, position FROM collection_recipes WHERE collection_id = ? ORDER BY position" + ); + const rawCollections = database + .prepare("SELECT * FROM collections ORDER BY id") + .all() as unknown as Array<{ + id: string; + name: string; + description: string | null; + source_json: string; + deleted_at: string | null; + }>; + + const collections: CollectionBackupRecord[] = rawCollections.map((coll) => ({ + ...coll, + recipes: collRecipeStmt.all(coll.id) as any[], + })); + + // 9. Inventory locations + let inventory_locations: InventoryLocationBackupRecord[] = []; + try { + inventory_locations = database + .prepare("SELECT id, name, position, deleted_at FROM inventory_locations ORDER BY position, id") + .all() as unknown as InventoryLocationBackupRecord[]; + } catch {} + + // 10. Inventory counts & count items + let inventory_counts: InventoryCountBackupRecord[] = []; + try { + const countItemStmt = database.prepare( + "SELECT location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost FROM inventory_count_items WHERE count_id = ? ORDER BY location_id, ingredient_id" + ); + const rawCounts = database + .prepare("SELECT id, title, counted_at, status, notes, created_at, deleted_at FROM inventory_counts ORDER BY counted_at DESC, id") + .all() as unknown as Array<{ + id: string; + title: string; + counted_at: string; + status: string; + notes: string | null; + created_at: string; + deleted_at: string | null; + }>; + + inventory_counts = rawCounts.map((count) => ({ + ...count, + items: countItemStmt.all(count.id) as any[], + })); + } catch {} + + const summary: BackupSummary = { + units_count: units.length, + equipment_count: equipment.length, + prep_actions_count: prepActions.length, + ingredients_count: ingredients.length, + recipes_count: recipes.length, + purchase_items_count: purchase_items.length, + price_observations_count: totalPricesCount, + source_mappings_count: source_mappings.length, + collections_count: collections.length, + inventory_locations_count: inventory_locations.length, + inventory_counts_count: inventory_counts.length, + total_records_count: + units.length + + equipment.length + + prepActions.length + + ingredients.length + + recipes.length + + purchase_items.length + + source_mappings.length + + collections.length + + inventory_locations.length + + inventory_counts.length, + }; + + return { + $schema: "https://formulation.app/schemas/backup-v1.json", + format_version: "1.0.0", + app_version: "2.0.0", + exported_at: new Date().toISOString(), + summary, + data: { + units, + equipment, + prep_actions: prepActions, + ingredients, + recipes, + purchase_items, + source_mappings, + collections, + inventory_locations, + inventory_counts, + }, + }; +} diff --git a/src/lib/backup/import-database.ts b/src/lib/backup/import-database.ts new file mode 100644 index 0000000..5a343c5 --- /dev/null +++ b/src/lib/backup/import-database.ts @@ -0,0 +1,482 @@ +import type { DatabaseSync } from "node:sqlite"; +import { validateBackupBundle } from "./validate-backup.ts"; +import { writeSiteProjection } from "../../../scripts/lib/site-projection.mjs"; +import type { + FormulationBackupBundle, + ImportOptions, + ImportResult, +} from "./types.ts"; + +export function importDatabase( + database: DatabaseSync, + bundle: FormulationBackupBundle, + options: ImportOptions = {} +): ImportResult { + const mode = options.mode ?? "replace"; + const validation = validateBackupBundle(bundle); + + if (!validation.valid) { + throw new Error( + `Backup validation failed: ${validation.errors.join("; ")}` + ); + } + + const { data, summary } = bundle; + + database.exec("BEGIN IMMEDIATE;"); + try { + database.exec("PRAGMA defer_foreign_keys = ON;"); + + if (mode === "replace") { + // Clear tables in reverse dependency order + const tables = [ + "inventory_count_items", + "inventory_counts", + "inventory_locations", + "collection_recipes", + "collections", + "source_mappings", + "price_observations", + "purchase_items", + "recipe_media", + "recipe_measure_conversions", + "step_equipment", + "recipe_equipment", + "recipe_steps", + "item_prep_actions", + "recipe_items", + "recipe_components", + "recipes", + "ingredient_density_measurements", + "ingredient_measure_conversions", + "ingredient_prep_actions", + "ingredient_aliases", + "ingredients", + "prep_actions", + "equipment", + "units", + ]; + + for (const table of tables) { + try { + database.exec(`DELETE FROM ${table};`); + } catch { + // Table might not exist in old migration environments + } + } + } + + // 1. Units + const unitStmt = database.prepare( + "INSERT OR REPLACE INTO units(id, name, symbol, dimension, system, base_unit_id, factor, offset) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const u of data.units ?? []) { + unitStmt.run( + u.id, + u.name, + u.symbol, + u.dimension, + u.system, + u.base_unit_id ?? null, + u.factor ?? null, + u.offset ?? null + ); + } + + // 2. Equipment + const eqStmt = database.prepare( + "INSERT OR REPLACE INTO equipment(id, name, category, notes) VALUES (?, ?, ?, ?)" + ); + for (const eq of data.equipment ?? []) { + eqStmt.run(eq.id, eq.name, eq.category ?? null, eq.notes ?? null); + } + + // 3. Prep Actions + const prepStmt = database.prepare( + "INSERT OR REPLACE INTO prep_actions(id, name, action_type, default_yield_factor, notes) VALUES (?, ?, ?, ?, ?)" + ); + for (const pa of data.prep_actions ?? []) { + prepStmt.run( + pa.id, + pa.name, + pa.action_type, + pa.default_yield_factor ?? null, + pa.notes ?? null + ); + } + + // 4. Ingredients & sub-tables + const ingStmt = database.prepare( + "INSERT OR REPLACE INTO ingredients(id, schema_version, name, status, categories_json, tags_json, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + const aliasStmt = database.prepare( + "INSERT OR REPLACE INTO ingredient_aliases(ingredient_id, name, kind) VALUES (?, ?, ?)" + ); + const ingPrepStmt = database.prepare( + "INSERT OR REPLACE INTO ingredient_prep_actions(ingredient_id, action_id, yield_factor, notes) VALUES (?, ?, ?, ?)" + ); + const ingConvStmt = database.prepare( + "INSERT OR REPLACE INTO ingredient_measure_conversions(ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, state, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + const ingDensityStmt = database.prepare( + "INSERT OR REPLACE INTO ingredient_density_measurements(ingredient_id, id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, temperature_c, state, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + + for (const ing of data.ingredients ?? []) { + ingStmt.run( + ing.id, + ing.schema_version ?? 2, + ing.name, + ing.status ?? "active", + ing.categories_json ?? "[]", + ing.tags_json ?? "[]", + ing.description ?? null, + ing.source_json ?? "{}", + ing.deleted_at ?? null + ); + + if (mode === "merge") { + database.prepare("DELETE FROM ingredient_aliases WHERE ingredient_id = ?").run(ing.id); + database.prepare("DELETE FROM ingredient_prep_actions WHERE ingredient_id = ?").run(ing.id); + database.prepare("DELETE FROM ingredient_measure_conversions WHERE ingredient_id = ?").run(ing.id); + database.prepare("DELETE FROM ingredient_density_measurements WHERE ingredient_id = ?").run(ing.id); + } + + for (const alias of ing.aliases ?? []) { + aliasStmt.run(ing.id, alias.name, alias.kind ?? null); + } + for (const pa of ing.prep_actions ?? []) { + ingPrepStmt.run(ing.id, pa.action_id, pa.yield_factor, pa.notes ?? null); + } + for (const conv of ing.measure_conversions ?? []) { + ingConvStmt.run( + ing.id, + conv.id, + conv.from_quantity, + conv.from_unit_id, + conv.to_quantity, + conv.to_unit_id, + conv.state ?? null, + conv.source_json ?? "{}" + ); + } + for (const density of ing.density_measurements ?? []) { + ingDensityStmt.run( + ing.id, + density.id, + density.mass_quantity, + density.mass_unit_id, + density.volume_quantity, + density.volume_unit_id, + density.temperature_c ?? null, + density.state ?? null, + density.source_json ?? "{}" + ); + } + } + + // 5. Recipes & sub-tables + const recStmt = database.prepare( + `INSERT OR REPLACE INTO recipes( + id, schema_version, save_version, title, summary, + categories_json, tags_json, yield_quantity, yield_unit_id, + yield_servings, yield_basis, scaling_mode, scaling_basis_id, + scaling_basis_quantity, scaling_basis_unit_id, notes_json, + source_json, auto_yield, station, deleted_at, cover_media_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + const recEqStmt = database.prepare( + "INSERT OR REPLACE INTO recipe_equipment(recipe_id, equipment_id) VALUES (?, ?)" + ); + const compStmt = database.prepare( + "INSERT OR REPLACE INTO recipe_components(recipe_id, id, position, name, notes_json) VALUES (?, ?, ?, ?, ?)" + ); + const itemStmt = database.prepare( + `INSERT OR REPLACE INTO recipe_items( + recipe_id, component_id, id, position, ingredient_id, + subrecipe_id, quantity, unit_id, percentage, basis_member, + optional, notes, nutrition_retention_factor + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + const itemPrepStmt = database.prepare( + "INSERT OR REPLACE INTO item_prep_actions(recipe_id, item_id, position, action_id, yield_factor, notes) VALUES (?, ?, ?, ?, ?, ?)" + ); + const stepStmt = database.prepare( + "INSERT OR REPLACE INTO recipe_steps(recipe_id, id, position, instruction, critical_control_point) VALUES (?, ?, ?, ?, ?)" + ); + const stepEqStmt = database.prepare( + "INSERT OR REPLACE INTO step_equipment(recipe_id, step_id, equipment_id) VALUES (?, ?, ?)" + ); + const recConvStmt = database.prepare( + "INSERT OR REPLACE INTO recipe_measure_conversions(recipe_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, notes, source_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + const mediaStmt = database.prepare( + "INSERT OR REPLACE INTO recipe_media(recipe_id, id, step_id, media_type, url, caption, position) VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + for (const rec of data.recipes ?? []) { + recStmt.run( + rec.id, + rec.schema_version ?? 2, + rec.save_version ?? 1, + rec.title, + rec.summary ?? null, + rec.categories_json ?? "[]", + rec.tags_json ?? "[]", + rec.yield_quantity, + rec.yield_unit_id, + rec.yield_servings ?? null, + rec.yield_basis ?? null, + rec.scaling_mode ?? null, + rec.scaling_basis_id ?? null, + rec.scaling_basis_quantity ?? null, + rec.scaling_basis_unit_id ?? null, + rec.notes_json ?? "[]", + rec.source_json ?? "{}", + rec.auto_yield ?? 0, + rec.station ?? null, + rec.deleted_at ?? null, + rec.cover_media_url ?? null + ); + + if (mode === "merge") { + database.prepare("DELETE FROM recipe_equipment WHERE recipe_id = ?").run(rec.id); + database.prepare("DELETE FROM recipe_components WHERE recipe_id = ?").run(rec.id); + database.prepare("DELETE FROM recipe_items WHERE recipe_id = ?").run(rec.id); + database.prepare("DELETE FROM recipe_steps WHERE recipe_id = ?").run(rec.id); + database.prepare("DELETE FROM recipe_measure_conversions WHERE recipe_id = ?").run(rec.id); + database.prepare("DELETE FROM recipe_media WHERE recipe_id = ?").run(rec.id); + } + + for (const eqId of rec.equipment_ids ?? []) { + recEqStmt.run(rec.id, eqId); + } + + for (const comp of rec.components ?? []) { + compStmt.run(rec.id, comp.id, comp.position, comp.name, comp.notes_json ?? "[]"); + } + + for (const item of rec.items ?? []) { + itemStmt.run( + rec.id, + item.component_id, + item.id, + item.position, + item.ingredient_id ?? null, + item.subrecipe_id ?? null, + item.quantity, + item.unit_id, + item.percentage ?? null, + item.basis_member ?? 0, + item.optional ?? 0, + item.notes ?? null, + item.nutrition_retention_factor ?? 1 + ); + + for (const ipa of item.prep_actions ?? []) { + itemPrepStmt.run( + rec.id, + item.id, + ipa.position, + ipa.action_id, + ipa.yield_factor ?? null, + ipa.notes ?? null + ); + } + } + + for (const step of rec.steps ?? []) { + stepStmt.run( + rec.id, + step.id, + step.position, + step.instruction, + step.critical_control_point ?? 0 + ); + + for (const eqId of step.equipment_ids ?? []) { + stepEqStmt.run(rec.id, step.id, eqId); + } + } + + for (const conv of rec.measure_conversions ?? []) { + recConvStmt.run( + rec.id, + conv.id, + conv.from_quantity, + conv.from_unit_id, + conv.to_quantity, + conv.to_unit_id, + conv.notes ?? null, + conv.source_json ?? "{}" + ); + } + + for (const m of rec.media ?? []) { + mediaStmt.run( + rec.id, + m.id, + m.step_id ?? null, + m.media_type, + m.url, + m.caption ?? null, + m.position + ); + } + } + + // 6. Purchase items & prices + const piStmt = database.prepare( + `INSERT OR REPLACE INTO purchase_items( + id, ingredient_id, name, brand, supplier_id, + supplier_sku, status, package_quantity, package_unit_id, + units_per_case, usable_yield_factor, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + const priceStmt = database.prepare( + "INSERT OR REPLACE INTO price_observations(purchase_item_id, effective_at, currency, amount, source_json) VALUES (?, ?, ?, ?, ?)" + ); + + for (const pi of data.purchase_items ?? []) { + piStmt.run( + pi.id, + pi.ingredient_id, + pi.name, + pi.brand ?? null, + pi.supplier_id ?? null, + pi.supplier_sku ?? null, + pi.status ?? "active", + pi.package_quantity, + pi.package_unit_id, + pi.units_per_case ?? 1, + pi.usable_yield_factor ?? 1, + pi.deleted_at ?? null + ); + + if (mode === "merge") { + database.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(pi.id); + } + + for (const price of pi.prices ?? []) { + priceStmt.run( + pi.id, + price.effective_at, + price.currency, + price.amount, + price.source_json ?? "{}" + ); + } + } + + // 7. Source mappings + const smStmt = database.prepare( + "INSERT OR REPLACE INTO source_mappings(id, subject_type, subject_id, mapping_type, status, source_json, nutrition_json) VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + for (const sm of data.source_mappings ?? []) { + smStmt.run( + sm.id, + sm.subject_type, + sm.subject_id, + sm.mapping_type, + sm.status, + sm.source_json ?? "{}", + sm.nutrition_json ?? null + ); + } + + // 8. Collections & collection recipes + const collStmt = database.prepare( + "INSERT OR REPLACE INTO collections(id, name, description, source_json, deleted_at) VALUES (?, ?, ?, ?, ?)" + ); + const collRecStmt = database.prepare( + "INSERT OR REPLACE INTO collection_recipes(collection_id, recipe_id, position) VALUES (?, ?, ?)" + ); + + for (const coll of data.collections ?? []) { + collStmt.run( + coll.id, + coll.name, + coll.description ?? null, + coll.source_json ?? "{}", + coll.deleted_at ?? null + ); + + if (mode === "merge") { + database.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(coll.id); + } + + for (const cr of coll.recipes ?? []) { + collRecStmt.run(coll.id, cr.recipe_id, cr.position); + } + } + + // 9. Inventory locations + if (Array.isArray(data.inventory_locations) && data.inventory_locations.length > 0) { + const locStmt = database.prepare( + "INSERT OR REPLACE INTO inventory_locations(id, name, position, deleted_at) VALUES (?, ?, ?, ?)" + ); + for (const loc of data.inventory_locations) { + locStmt.run(loc.id, loc.name, loc.position, loc.deleted_at ?? null); + } + } + + // 10. Inventory counts & count items + if (Array.isArray(data.inventory_counts) && data.inventory_counts.length > 0) { + const countStmt = database.prepare( + "INSERT OR REPLACE INTO inventory_counts(id, title, counted_at, status, notes, created_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + const countItemStmt = database.prepare( + "INSERT OR REPLACE INTO inventory_count_items(count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + + for (const count of data.inventory_counts) { + countStmt.run( + count.id, + count.title, + count.counted_at, + count.status ?? "open", + count.notes ?? null, + count.created_at, + count.deleted_at ?? null + ); + + if (mode === "merge") { + database.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(count.id); + } + + for (const item of count.items ?? []) { + countItemStmt.run( + count.id, + item.location_id ?? null, + item.ingredient_id, + item.quantity, + item.unit_id, + item.unit_cost ?? null, + item.extended_cost ?? null + ); + } + } + } + + database.exec("COMMIT;"); + } catch (error) { + database.exec("ROLLBACK;"); + throw error; + } + + if (options.rebuildProjections !== false) { + try { + writeSiteProjection(database); + } catch (err) { + console.warn("Failed to rebuild site projection after import:", err); + } + } + + return { + success: true, + mode, + imported_at: new Date().toISOString(), + summary, + message: `Successfully imported ${summary.total_records_count} records (${summary.recipes_count} recipes, ${summary.ingredients_count} ingredients) in '${mode}' mode.`, + }; +} diff --git a/src/lib/backup/index.ts b/src/lib/backup/index.ts new file mode 100644 index 0000000..e967d9f --- /dev/null +++ b/src/lib/backup/index.ts @@ -0,0 +1,4 @@ +export * from "./types.ts"; +export * from "./export-database.ts"; +export * from "./import-database.ts"; +export * from "./validate-backup.ts"; diff --git a/src/lib/backup/types.ts b/src/lib/backup/types.ts new file mode 100644 index 0000000..e2a63d6 --- /dev/null +++ b/src/lib/backup/types.ts @@ -0,0 +1,297 @@ +export interface BackupMetadata { + $schema: "https://formulation.app/schemas/backup-v1.json"; + format_version: "1.0.0"; + app_version: string; + exported_at: string; + database_version?: number; +} + +export interface BackupSummary { + units_count: number; + equipment_count: number; + prep_actions_count: number; + ingredients_count: number; + recipes_count: number; + purchase_items_count: number; + price_observations_count: number; + source_mappings_count: number; + collections_count: number; + inventory_locations_count: number; + inventory_counts_count: number; + total_records_count: number; +} + +export interface UnitBackupRecord { + id: string; + name: string; + symbol: string; + dimension: string; + system: string; + base_unit_id: string | null; + factor: number | null; + offset: number | null; +} + +export interface EquipmentBackupRecord { + id: string; + name: string; + category: string | null; + notes: string | null; +} + +export interface PrepActionBackupRecord { + id: string; + name: string; + action_type: string; + default_yield_factor: number | null; + notes: string | null; +} + +export interface IngredientAliasBackupRecord { + name: string; + kind: string | null; +} + +export interface IngredientPrepActionBackupRecord { + action_id: string; + yield_factor: number; + notes: string | null; +} + +export interface IngredientMeasureConversionBackupRecord { + id: string; + from_quantity: number; + from_unit_id: string; + to_quantity: number; + to_unit_id: string; + state: string | null; + source_json: string; +} + +export interface IngredientDensityMeasurementBackupRecord { + id: string; + mass_quantity: number; + mass_unit_id: string; + volume_quantity: number; + volume_unit_id: string; + temperature_c: number | null; + state: string | null; + source_json: string; +} + +export interface IngredientBackupRecord { + id: string; + schema_version: number; + name: string; + status: string; + categories_json: string; + tags_json: string; + description: string | null; + source_json: string; + deleted_at: string | null; + aliases: IngredientAliasBackupRecord[]; + prep_actions: IngredientPrepActionBackupRecord[]; + measure_conversions: IngredientMeasureConversionBackupRecord[]; + density_measurements: IngredientDensityMeasurementBackupRecord[]; +} + +export interface ItemPrepActionBackupRecord { + position: number; + action_id: string; + yield_factor: number | null; + notes: string | null; +} + +export interface RecipeItemBackupRecord { + component_id: string; + id: string; + position: number; + ingredient_id: string | null; + subrecipe_id: string | null; + quantity: number; + unit_id: string; + percentage: number | null; + basis_member: number; + optional: number; + notes: string | null; + nutrition_retention_factor: number; + prep_actions: ItemPrepActionBackupRecord[]; +} + +export interface RecipeComponentBackupRecord { + id: string; + position: number; + name: string; + notes_json: string; +} + +export interface RecipeStepBackupRecord { + id: string; + position: number; + instruction: string; + critical_control_point: number; + equipment_ids: string[]; +} + +export interface RecipeMeasureConversionBackupRecord { + id: string; + from_quantity: number; + from_unit_id: string; + to_quantity: number; + to_unit_id: string; + notes: string | null; + source_json: string; +} + +export interface RecipeMediaBackupRecord { + id: string; + step_id: string | null; + media_type: string; + url: string; + caption: string | null; + position: number; +} + +export interface RecipeBackupRecord { + id: string; + schema_version: number; + save_version: number; + title: string; + summary: string | null; + categories_json: string; + tags_json: string; + yield_quantity: number; + yield_unit_id: string; + yield_servings: number | null; + yield_basis: string | null; + scaling_mode: string | null; + scaling_basis_id: string | null; + scaling_basis_quantity: number | null; + scaling_basis_unit_id: string | null; + notes_json: string; + source_json: string; + auto_yield: number; + station: string | null; + deleted_at: string | null; + cover_media_url: string | null; + equipment_ids: string[]; + components: RecipeComponentBackupRecord[]; + items: RecipeItemBackupRecord[]; + steps: RecipeStepBackupRecord[]; + measure_conversions: RecipeMeasureConversionBackupRecord[]; + media: RecipeMediaBackupRecord[]; +} + +export interface PriceObservationBackupRecord { + effective_at: string; + currency: string; + amount: number; + source_json: string; +} + +export interface PurchaseItemBackupRecord { + id: string; + ingredient_id: string; + name: string; + brand: string | null; + supplier_id: string | null; + supplier_sku: string | null; + status: string; + package_quantity: number; + package_unit_id: string; + units_per_case: number; + usable_yield_factor: number; + deleted_at: string | null; + prices: PriceObservationBackupRecord[]; +} + +export interface SourceMappingBackupRecord { + id: string; + subject_type: string; + subject_id: string; + mapping_type: string; + status: string; + source_json: string; + nutrition_json: string | null; +} + +export interface CollectionRecipeBackupRecord { + recipe_id: string; + position: number; +} + +export interface CollectionBackupRecord { + id: string; + name: string; + description: string | null; + source_json: string; + deleted_at: string | null; + recipes: CollectionRecipeBackupRecord[]; +} + +export interface InventoryLocationBackupRecord { + id: string; + name: string; + position: number; + deleted_at: string | null; +} + +export interface InventoryCountItemBackupRecord { + location_id: string | null; + ingredient_id: string; + quantity: number; + unit_id: string; + unit_cost: number | null; + extended_cost: number | null; +} + +export interface InventoryCountBackupRecord { + id: string; + title: string; + counted_at: string; + status: string; + notes: string | null; + created_at: string; + deleted_at: string | null; + items: InventoryCountItemBackupRecord[]; +} + +export interface FormulationBackupData { + units: UnitBackupRecord[]; + equipment: EquipmentBackupRecord[]; + prep_actions: PrepActionBackupRecord[]; + ingredients: IngredientBackupRecord[]; + recipes: RecipeBackupRecord[]; + purchase_items: PurchaseItemBackupRecord[]; + source_mappings: SourceMappingBackupRecord[]; + collections: CollectionBackupRecord[]; + inventory_locations: InventoryLocationBackupRecord[]; + inventory_counts: InventoryCountBackupRecord[]; +} + +export interface FormulationBackupBundle extends BackupMetadata { + summary: BackupSummary; + data: FormulationBackupData; +} + +export type ImportMode = "replace" | "merge"; + +export interface ImportOptions { + mode?: ImportMode; + rebuildProjections?: boolean; +} + +export interface ImportResult { + success: boolean; + mode: ImportMode; + imported_at: string; + summary: BackupSummary; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; + summary?: BackupSummary; +} diff --git a/src/lib/backup/validate-backup.ts b/src/lib/backup/validate-backup.ts new file mode 100644 index 0000000..beac7d0 --- /dev/null +++ b/src/lib/backup/validate-backup.ts @@ -0,0 +1,110 @@ +import type { FormulationBackupBundle, ValidationResult, BackupSummary } from "./types"; + +export function validateBackupBundle(input: unknown): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (!input || typeof input !== "object") { + return { + valid: false, + errors: ["Invalid backup payload: expected a JSON object."], + warnings: [], + }; + } + + const bundle = input as Partial; + + if (!bundle.format_version) { + errors.push("Missing 'format_version' in backup metadata."); + } else if (!bundle.format_version.startsWith("1.")) { + errors.push(`Unsupported backup format_version: '${bundle.format_version}'. Expected 1.x.`); + } + + if (!bundle.data || typeof bundle.data !== "object") { + errors.push("Missing 'data' container in backup payload."); + return { valid: false, errors, warnings }; + } + + const { data } = bundle; + + if (!Array.isArray(data.units)) { + errors.push("Missing 'data.units' array in backup payload."); + } + if (!Array.isArray(data.ingredients)) { + errors.push("Missing 'data.ingredients' array in backup payload."); + } + if (!Array.isArray(data.recipes)) { + errors.push("Missing 'data.recipes' array in backup payload."); + } + + // Check unit integrity + const unitIds = new Set(); + if (Array.isArray(data.units)) { + for (const unit of data.units) { + if (!unit.id || !unit.name || !unit.dimension || !unit.system) { + errors.push(`Unit entry is missing required fields: ${JSON.stringify(unit)}`); + break; + } + unitIds.add(unit.id); + } + } + + // Check ingredient integrity + const ingredientIds = new Set(); + if (Array.isArray(data.ingredients)) { + for (const ingredient of data.ingredients) { + if (!ingredient.id || !ingredient.name) { + errors.push(`Ingredient entry is missing required fields (id, name): ${JSON.stringify(ingredient)}`); + break; + } + ingredientIds.add(ingredient.id); + } + } + + // Check recipe integrity + if (Array.isArray(data.recipes)) { + for (const recipe of data.recipes) { + if (!recipe.id || !recipe.title || !recipe.yield_unit_id) { + errors.push(`Recipe entry is missing required fields (id, title, yield_unit_id): ${JSON.stringify(recipe)}`); + break; + } + if (unitIds.size > 0 && !unitIds.has(recipe.yield_unit_id)) { + warnings.push(`Recipe '${recipe.title}' (${recipe.id}) references unknown yield unit '${recipe.yield_unit_id}'.`); + } + } + } + + const summary: BackupSummary = bundle.summary ?? { + units_count: Array.isArray(data.units) ? data.units.length : 0, + equipment_count: Array.isArray(data.equipment) ? data.equipment.length : 0, + prep_actions_count: Array.isArray(data.prep_actions) ? data.prep_actions.length : 0, + ingredients_count: Array.isArray(data.ingredients) ? data.ingredients.length : 0, + recipes_count: Array.isArray(data.recipes) ? data.recipes.length : 0, + purchase_items_count: Array.isArray(data.purchase_items) ? data.purchase_items.length : 0, + price_observations_count: Array.isArray(data.purchase_items) + ? data.purchase_items.reduce((acc, pi) => acc + (pi.prices?.length ?? 0), 0) + : 0, + source_mappings_count: Array.isArray(data.source_mappings) ? data.source_mappings.length : 0, + collections_count: Array.isArray(data.collections) ? data.collections.length : 0, + inventory_locations_count: Array.isArray(data.inventory_locations) ? data.inventory_locations.length : 0, + inventory_counts_count: Array.isArray(data.inventory_counts) ? data.inventory_counts.length : 0, + total_records_count: + (data.units?.length ?? 0) + + (data.equipment?.length ?? 0) + + (data.prep_actions?.length ?? 0) + + (data.ingredients?.length ?? 0) + + (data.recipes?.length ?? 0) + + (data.purchase_items?.length ?? 0) + + (data.source_mappings?.length ?? 0) + + (data.collections?.length ?? 0) + + (data.inventory_locations?.length ?? 0) + + (data.inventory_counts?.length ?? 0), + }; + + return { + valid: errors.length === 0, + errors, + warnings, + summary, + }; +} diff --git a/src/lib/costing.ts b/src/lib/costing.ts index 35e6a6a..6d6f184 100644 --- a/src/lib/costing.ts +++ b/src/lib/costing.ts @@ -1,5 +1,5 @@ -import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types"; -import { convertWithIngredientMeasures } from "./measurement"; +import type { Ingredient, PrepAction, PurchaseItem, Recipe, RecipeItem, Unit } from "./types.ts"; +import { convertWithIngredientMeasures } from "./measurement.ts"; export type CostResult = { batch?: number; diff --git a/src/lib/data.ts b/src/lib/data.ts index d892d07..48be68c 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -11,7 +11,7 @@ import { databaseProjection, openDatabase } from "./database"; export function loadCatalogs() { const database = openDatabase(); - if (!database) throw new Error("Database unavailable. Run npm run db:reset first."); + if (!database) throw new Error("Database unavailable. Restore from backup with: npm run db:restore -- path/to/backup.json"); try { const projection = databaseProjection(database) as { ingredients: Ingredient[]; diff --git a/src/lib/format.ts b/src/lib/format.ts index c354620..ca5f563 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -1,4 +1,4 @@ -import type { Amount, Unit } from "./types"; +import type { Amount, Unit } from "./types.ts"; export function displayDigits(value: number) { return value !== 0 && Math.abs(value) <= 0.1 ? 3 : 2; diff --git a/src/lib/measurement.ts b/src/lib/measurement.ts index 490d2c4..c0cf509 100644 --- a/src/lib/measurement.ts +++ b/src/lib/measurement.ts @@ -1,4 +1,4 @@ -import type { Amount, DensityMeasurement, Ingredient, Unit } from "./types"; +import type { Amount, DensityMeasurement, Ingredient, Unit } from "./types.ts"; export type UnitCatalog = Map | Record; diff --git a/src/lib/nutrition.ts b/src/lib/nutrition.ts index 6edee34..b8fe42b 100644 --- a/src/lib/nutrition.ts +++ b/src/lib/nutrition.ts @@ -1,5 +1,5 @@ -import type { Ingredient, Recipe, SourceMapping, Unit } from "./types"; -import { convertWithIngredientMeasures } from "./measurement"; +import type { Ingredient, Recipe, SourceMapping, Unit } from "./types.ts"; +import { convertWithIngredientMeasures } from "./measurement.ts"; export type NutritionFacts = Record; export type NutritionResult = { diff --git a/src/mcp/mcp.test.ts b/src/mcp/mcp.test.ts new file mode 100644 index 0000000..3d136c6 --- /dev/null +++ b/src/mcp/mcp.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from "vitest"; +import { openDatabase } from "../lib/database"; +import { createMcpTools } from "./tools"; + +describe("MCP Tools & Domain Integration", () => { + it("searches recipes with keywords and filters", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + const all = tools.searchRecipes({ limit: 10 }); + expect(all.length).toBeGreaterThan(0); + expect(all[0]).toHaveProperty("id"); + expect(all[0]).toHaveProperty("title"); + expect(all[0]).toHaveProperty("yield"); + + const filtered = tools.searchRecipes({ query: "biscotti" }); + expect(filtered.length).toBeGreaterThan(0); + expect(filtered.some((r) => r.id === "chocolate_biscotti")).toBe(true); + } finally { + db.close(); + } + }); + + it("retrieves full recipe with scaling calculation", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + const base = tools.getRecipe({ id: "chocolate_biscotti" }); + expect(base.id).toBe("chocolate_biscotti"); + expect(base.components.length).toBeGreaterThan(0); + expect(base.steps.length).toBeGreaterThan(0); + expect(base.scale_factor).toBe(1); + + // Scale by 2x + const doubled = tools.getRecipe({ id: "chocolate_biscotti", scale_factor: 2 }); + expect(doubled.scale_factor).toBe(2); + expect(doubled.yield.quantity).toBe(base.yield.quantity * 2); + expect(doubled.components[0].items[0].quantity).toBe( + base.components[0].items[0].base_quantity * 2 + ); + } finally { + db.close(); + } + }); + + it("runs recipe quality audit", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + const audit = tools.auditRecipeQuality(); + expect(audit.summary.total).toBeGreaterThan(0); + expect(audit.recipes.length).toBe(audit.summary.total); + expect(audit.recipes[0]).toHaveProperty("placeholder_steps"); + expect(audit.recipes[0]).toHaveProperty("unpriced_items"); + } finally { + db.close(); + } + }); + + it("searches ingredients and retrieves ingredient detail", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + const ings = tools.searchIngredients({ query: "flour", limit: 5 }); + expect(ings.length).toBeGreaterThan(0); + + const flour = tools.getIngredient({ id: "flour_all_purpose" }); + expect(flour.id).toBe("flour_all_purpose"); + expect(flour.name.toLowerCase()).toContain("flour"); + expect(Array.isArray(flour.density_measurements)).toBe(true); + expect(Array.isArray(flour.measure_conversions)).toBe(true); + } finally { + db.close(); + } + }); + + it("creates, updates, and deletes an ingredient", () => { + const db = openDatabase({ readOnly: false }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + + const createRes = tools.saveIngredient({ + name: "Test Matcha Powder", + description: "Ceremonial grade green tea powder", + categories: ["tea", "flavoring"], + tags: ["beverage", "japanese"], + aliases: [{ name: "Matcha", kind: "search" }], + density: { + mass_quantity: 60, + mass_unit_id: "gram", + volume_quantity: 0.25, + volume_unit_id: "cup_us", + }, + }); + expect(createRes.success).toBe(true); + expect(createRes.created).toBe(true); + const ingId = createRes.ingredient_id; + + const fetched = tools.getIngredient({ id: ingId }); + expect(fetched.name).toBe("Test Matcha Powder"); + expect(fetched.categories).toContain("tea"); + expect(fetched.density_measurements.length).toBe(1); + + const updateRes = tools.saveIngredient({ + id: ingId, + name: "Test Matcha Powder Organic", + categories: ["tea", "flavoring", "organic"], + }); + expect(updateRes.success).toBe(true); + + const deleteRes = tools.deleteIngredient({ id: ingId }); + expect(deleteRes.success).toBe(true); + } finally { + db.close(); + } + }); + + it("prevents deleting an ingredient that is in active use", () => { + const db = openDatabase({ readOnly: false }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + expect(() => tools.deleteIngredient({ id: "sugar" })).toThrow(/used in active recipe/); + } finally { + db.close(); + } + }); + + it("manages purchase items and records price observations", () => { + const db = openDatabase({ readOnly: false }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + + // Create purchase item with initial price + const createRes = tools.savePurchaseItem({ + ingredient_id: "flour_all_purpose", + name: "King Arthur AP Flour 25 lb", + brand: "King Arthur", + supplier_id: "sysco", + package_quantity: 25, + package_unit_id: "pound", + initial_price: { + amount: 18.50, + currency: "USD", + }, + }); + expect(createRes.success).toBe(true); + const piId = createRes.purchase_item_id; + + // Get detail + const detail = tools.getPurchaseItem({ id: piId }); + expect(detail.name).toBe("King Arthur AP Flour 25 lb"); + expect(detail.price_history.length).toBe(1); + expect(detail.price_history[0].amount).toBe(18.50); + + // Record new price observation + const priceRes = tools.recordPriceObservation({ + purchase_item_id: piId, + amount: 19.25, + currency: "USD", + effective_at: "2026-08-17", + }); + expect(priceRes.success).toBe(true); + + // Clean up + db.prepare("DELETE FROM price_observations WHERE purchase_item_id = ?").run(piId); + db.prepare("DELETE FROM purchase_items WHERE id = ?").run(piId); + } finally { + db.close(); + } + }); + + it("lists reference catalogs: units, equipment, and prep actions", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + + const units = tools.listUnits({ dimension: "mass" }); + expect(units.length).toBeGreaterThan(0); + expect(units.every((u) => u.dimension === "mass")).toBe(true); + + const equip = tools.listEquipment(); + expect(equip.length).toBeGreaterThan(0); + expect(equip[0]).toHaveProperty("id"); + expect(equip[0]).toHaveProperty("name"); + + const prep = tools.listPrepActions(); + expect(prep.length).toBeGreaterThan(0); + expect(prep[0]).toHaveProperty("action_type"); + } finally { + db.close(); + } + }); + + it("creates, retrieves, and updates recipe books", () => { + const db = openDatabase({ readOnly: false }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + + const createRes = tools.saveRecipeBook({ + name: "Test Holiday Pastries", + description: "Holiday baked goods collection", + recipe_ids: ["chocolate_biscotti", "cinnamon_sugar"], + }); + expect(createRes.success).toBe(true); + const bookId = createRes.book_id; + + const book = tools.getRecipeBook({ id: bookId }); + expect(book.name).toBe("Test Holiday Pastries"); + expect(book.recipe_count).toBe(2); + expect(book.recipes.length).toBe(2); + + const list = tools.listRecipeBooks(); + expect(list.some((b) => b.id === bookId)).toBe(true); + + db.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(bookId); + db.prepare("DELETE FROM collections WHERE id = ?").run(bookId); + } finally { + db.close(); + } + }); + + it("creates, retrieves, and updates inventory count sessions", () => { + const db = openDatabase({ readOnly: false }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + + const createRes = tools.createInventoryCountSession({ + title: "Test Week Count", + counted_at: "2026-08-17T12:00:00Z", + notes: "Automated test session", + prepopulate: true, + }); + expect(createRes.success).toBe(true); + const countId = createRes.count_id; + + const detail = tools.getInventoryCount({ id: countId }); + expect(detail.id).toBe(countId); + expect(detail.status).toBe("open"); + expect(detail.items.length).toBeGreaterThan(0); + + const updateRes = tools.updateInventoryCount({ + count_id: countId, + items: [{ ingredient_id: "sugar", quantity: 50, unit_id: "pound" }], + }); + expect(updateRes.success).toBe(true); + + db.prepare("DELETE FROM inventory_count_items WHERE count_id = ?").run(countId); + db.prepare("DELETE FROM inventory_counts WHERE id = ?").run(countId); + } finally { + db.close(); + } + }); + + it("converts units within dimension and cross-dimension with density", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + + const kgToG = tools.convertUnits({ + quantity: 1, + from_unit: "kilogram", + to_unit: "gram", + }); + expect(kgToG.to.quantity).toBe(1000); + + const cupToG = tools.convertUnits({ + ingredient_id: "salt", + quantity: 1, + from_unit: "cup_us", + to_unit: "gram", + }); + expect(cupToG.to.quantity).toBeCloseTo(292, 0); + } finally { + db.close(); + } + }); + + it("calculates recipe cost and nutrition", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + const cost = tools.calculateRecipeCost({ recipe_id: "chocolate_biscotti", currency: "USD" }); + expect(cost).toHaveProperty("currency", "USD"); + expect(cost).toHaveProperty("lines"); + + const nutrition = tools.calculateRecipeNutrition({ recipe_id: "chocolate_biscotti" }); + expect(nutrition).toHaveProperty("batch"); + expect(nutrition).toHaveProperty("inputWeightG"); + } finally { + db.close(); + } + }); + + it("lists archived items and exports database backup", () => { + const db = openDatabase({ readOnly: true }); + expect(db).toBeDefined(); + if (!db) return; + + try { + const tools = createMcpTools(() => db); + const stats = tools.getDatabaseStats(); + expect(stats.recipes).toBeGreaterThan(100); + expect(stats.ingredients).toBeGreaterThan(100); + + const archived = tools.listArchivedItems(); + expect(archived).toHaveProperty("total"); + expect(Array.isArray(archived.recipes)).toBe(true); + + const backup = tools.exportDatabaseBackup(); + expect(backup.format_version).toBe("1.0.0"); + expect(backup.summary.recipes_count).toBe(stats.recipes); + expect(backup.summary.ingredients_count).toBe(stats.ingredients); + } finally { + db.close(); + } + }); +}); diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..02c7dfa --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,362 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { openDatabase } from "../lib/database.ts"; +import { createMcpTools } from "./tools.ts"; + +export function createFormulationMcpServer() { + const server = new McpServer({ + name: "formulation", + version: "2.0.0", + }); + + const getDb = () => { + const db = openDatabase({ readOnly: false }); + if (!db) throw new Error("Formulation database unavailable at var/recipe-book.sqlite"); + return db; + }; + + const tools = createMcpTools(getDb); + + function wrap(fn: () => T | Promise) { + return async () => { + try { + const result = await fn(); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err: any) { + return { content: [{ type: "text" as const, text: `Error: ${err.message}` }], isError: true }; + } + }; + } + + // 1. Search Recipes + server.tool( + "search_recipes", + "Search recipes in the Formulation culinary database by keyword, category, or tag.", + { + query: z.string().optional().describe("Search keywords for title or ingredient"), + category: z.string().optional().describe("Filter by category (e.g. 'sauce', 'bread')"), + tag: z.string().optional().describe("Filter by tag"), + limit: z.number().int().min(1).max(100).default(25).describe("Max results"), + }, + async (args) => wrap(() => tools.searchRecipes(args))() + ); + + // 2. Get Recipe + server.tool( + "get_recipe", + "Get full recipe formulation with components, items, steps, and optional scaling.", + { + id: z.string().describe("Recipe ID"), + scale_factor: z.number().positive().optional().describe("Scale multiplier (e.g. 2 for double batch)"), + target_yield: z.number().positive().optional().describe("Target yield quantity to scale to"), + target_yield_unit: z.string().optional().describe("Target yield unit if different from recipe"), + }, + async (args) => wrap(() => tools.getRecipe(args))() + ); + + // 3. Save Recipe + server.tool( + "save_recipe", + "Create or update a recipe with validated components, items, and preparation steps.", + { + id: z.string().optional().describe("Recipe ID (omit to create new)"), + title: z.string().min(1).describe("Recipe title"), + summary: z.string().nullable().optional().describe("Brief culinary summary"), + yield_quantity: z.number().positive().describe("Finished yield quantity"), + yield_unit_id: z.string().describe("Yield unit (e.g. 'gram', 'each')"), + yield_servings: z.number().positive().nullable().optional().describe("Number of servings"), + yield_basis: z.string().optional().describe("Yield basis: theoretical, measured, estimated"), + categories: z.array(z.string()).optional().default([]), + tags: z.array(z.string()).optional().default([]), + components: z.array(z.object({ + id: z.string().optional(), + name: z.string().default("Main"), + items: z.array(z.object({ + id: z.string().optional(), + ingredient_id: z.string().optional(), + subrecipe_id: z.string().optional(), + quantity: z.number().positive(), + unit_id: z.string(), + percentage: z.number().nullable().optional(), + basis_member: z.boolean().default(false), + optional: z.boolean().default(false), + notes: z.string().nullable().optional(), + })), + })).min(1).describe("Components with ingredient line items"), + steps: z.array(z.object({ + id: z.string().optional(), + instruction: z.string().min(1), + equipment_ids: z.array(z.string()).optional(), + })).min(1).describe("Ordered preparation steps"), + notes: z.array(z.string()).optional().default([]), + }, + async (args) => wrap(() => tools.saveRecipe(args as any))() + ); + + // 4. Delete Recipe + server.tool( + "delete_recipe", + "Archive a recipe from the active formulation library.", + { id: z.string().describe("Recipe ID to archive") }, + async (args) => wrap(() => tools.deleteRecipe(args))() + ); + + // 5. Recipe Quality Audit + server.tool( + "audit_recipe_quality", + "Run automated quality audit on all recipes (unpriced items, placeholder steps, missing yield basis).", + {}, + async () => wrap(() => tools.auditRecipeQuality())() + ); + + // 6. Calculate Recipe Cost + server.tool( + "calculate_recipe_cost", + "Compute itemized ingredient costs, total batch cost, and cost per serving for a recipe.", + { + recipe_id: z.string().describe("Recipe ID"), + currency: z.string().default("USD").describe("Currency code"), + }, + async (args) => wrap(() => tools.calculateRecipeCost(args))() + ); + + // 7. Calculate Recipe Nutrition + server.tool( + "calculate_recipe_nutrition", + "Calculate nutrition facts (macros, vitamins) per 100g and per serving.", + { + recipe_id: z.string().describe("Recipe ID"), + serving_size_g: z.number().positive().optional().describe("Serving size in grams"), + }, + async (args) => wrap(() => tools.calculateRecipeNutrition(args))() + ); + + // 8. Search Ingredients + server.tool( + "search_ingredients", + "Search ingredients in the pantry catalog with cost and nutrition status.", + { + query: z.string().optional().describe("Search keywords"), + category: z.string().optional().describe("Filter by category"), + missing_cost: z.boolean().optional().describe("Show only ingredients missing purchase prices"), + limit: z.number().int().min(1).max(100).default(25).describe("Max results"), + }, + async (args) => wrap(() => tools.searchIngredients(args))() + ); + + // 9. Get Ingredient + server.tool( + "get_ingredient", + "Get detailed ingredient with density, equivalencies, aliases, and purchase prices.", + { id: z.string().describe("Ingredient ID") }, + async (args) => wrap(() => tools.getIngredient(args))() + ); + + // 10. Save Ingredient + server.tool( + "save_ingredient", + "Create or update an ingredient with category, aliases, and density data.", + { + id: z.string().optional().describe("Ingredient ID (omit to create new from name)"), + name: z.string().min(1).describe("Ingredient name"), + description: z.string().nullable().optional().describe("Culinary description"), + categories: z.array(z.string()).optional().default([]), + tags: z.array(z.string()).optional().default([]), + aliases: z.array(z.object({ name: z.string(), kind: z.string().optional() })).optional(), + density: z.object({ + mass_quantity: z.number().positive(), + mass_unit_id: z.string(), + volume_quantity: z.number().positive(), + volume_unit_id: z.string(), + }).optional().describe("Density measurement for volume-to-weight conversions"), + }, + async (args) => wrap(() => tools.saveIngredient(args))() + ); + + // 11. Delete Ingredient + server.tool( + "delete_ingredient", + "Archive an ingredient (fails safely if used in active recipes).", + { id: z.string().describe("Ingredient ID to archive") }, + async (args) => wrap(() => tools.deleteIngredient(args))() + ); + + // 12. Purchasing & Prices + server.tool( + "list_purchase_items", + "List purchase items with latest price observations.", + { + ingredient_id: z.string().optional().describe("Filter by ingredient ID"), + status: z.string().optional().describe("Filter by status (active, discontinued)"), + limit: z.number().int().min(1).max(200).default(50).describe("Max results"), + }, + async (args) => wrap(() => tools.listPurchaseItems(args))() + ); + + server.tool( + "get_purchase_item", + "Get purchase item with full price history observations.", + { id: z.string().describe("Purchase Item ID") }, + async (args) => wrap(() => tools.getPurchaseItem(args))() + ); + + server.tool( + "save_purchase_item", + "Create or update a purchase item with package specification and initial price.", + { + id: z.string().optional().describe("Purchase Item ID"), + ingredient_id: z.string().describe("Target ingredient ID"), + name: z.string().min(1).describe("Product package name"), + brand: z.string().nullable().optional(), + supplier_id: z.string().nullable().optional(), + supplier_sku: z.string().nullable().optional(), + package_quantity: z.number().positive().describe("Package quantity"), + package_unit_id: z.string().describe("Package unit ID (e.g. 'pound', 'gram')"), + units_per_case: z.number().positive().default(1), + usable_yield_factor: z.number().positive().default(1), + initial_price: z.object({ + amount: z.number().positive(), + currency: z.string().default("USD"), + effective_at: z.string().optional(), + }).optional(), + }, + async (args) => wrap(() => tools.savePurchaseItem(args as any))() + ); + + server.tool( + "record_price_observation", + "Record a new price observation for an existing purchase item.", + { + purchase_item_id: z.string().describe("Purchase Item ID"), + amount: z.number().positive().describe("Price amount"), + currency: z.string().default("USD").describe("Currency code"), + effective_at: z.string().optional().describe("Effective date (YYYY-MM-DD)"), + }, + async (args) => wrap(() => tools.recordPriceObservation(args))() + ); + + // 13. Convert Units + server.tool( + "convert_units", + "Convert culinary units using ingredient-specific density and equivalency data.", + { + ingredient_id: z.string().optional().describe("Ingredient ID for cross-dimension conversion"), + quantity: z.number().positive().describe("Quantity to convert"), + from_unit: z.string().describe("Source unit ID (e.g. 'cup_us', 'gram')"), + to_unit: z.string().describe("Target unit ID (e.g. 'gram', 'oz')"), + }, + async (args) => wrap(() => tools.convertUnits(args))() + ); + + // 14. Reference Catalogs + server.tool( + "list_units", + "List all measurement units (mass, volume, count, time, temperature) with base conversion factors.", + { + dimension: z.string().optional().describe("Filter by dimension (mass, volume, count, time, temperature, length)"), + system: z.string().optional().describe("Filter by system (metric, customary, si)"), + }, + async (args) => wrap(() => tools.listUnits(args))() + ); + + server.tool( + "list_equipment", + "List kitchen equipment items with categories and notes.", + { category: z.string().optional().describe("Filter by category") }, + async (args) => wrap(() => tools.listEquipment(args))() + ); + + server.tool( + "list_prep_actions", + "List culinary prep actions (peel, trim, chop) and default yield factors.", + {}, + async () => wrap(() => tools.listPrepActions())() + ); + + // 15. Inventory Counts + server.tool( + "list_inventory_counts", + "List inventory counting sessions with status and valuations.", + { status: z.enum(["all", "open", "completed"]).default("all").describe("Filter by status") }, + async (args) => wrap(() => tools.listInventoryCounts(args))() + ); + + server.tool( + "get_inventory_count", + "Get full inventory count sheet with counted items, locations, unit costs, and valuations.", + { id: z.string().describe("Inventory Count ID") }, + async (args) => wrap(() => tools.getInventoryCount(args))() + ); + + // 16. Recipe Books / Collections + server.tool( + "list_recipe_books", + "List recipe books (collections) with included recipe counts.", + {}, + async () => wrap(() => tools.listRecipeBooks())() + ); + + server.tool( + "get_recipe_book", + "Get recipe book details with ordered included recipes.", + { id: z.string().describe("Recipe Book ID") }, + async (args) => wrap(() => tools.getRecipeBook(args))() + ); + + server.tool( + "save_recipe_book", + "Create or update a recipe book with name, description, and included recipe IDs.", + { + id: z.string().optional().describe("Recipe book ID"), + name: z.string().min(1).describe("Recipe book name"), + description: z.string().nullable().optional().describe("Description"), + recipe_ids: z.array(z.string()).optional().describe("Ordered list of recipe IDs"), + }, + async (args) => wrap(() => tools.saveRecipeBook(args))() + ); + + // 17. Archive & Restore + server.tool( + "list_archived_items", + "List all archived (soft-deleted) items across recipes, ingredients, purchases, books, and inventory.", + {}, + async () => wrap(() => tools.listArchivedItems())() + ); + + server.tool( + "restore_archived_items", + "Restore archived items by ID and type (recipe, ingredient, purchase, book, inventory).", + { + items: z.array(z.object({ + id: z.string(), + type: z.enum(["recipe", "ingredient", "purchase", "book", "inventory"]), + })).min(1).describe("List of items to restore"), + }, + async (args) => wrap(() => tools.restoreArchived(args))() + ); + + // 18. Database Export & Stats + server.tool( + "export_database_backup", + "Export a complete JSON backup of the Formulation database (all 25 tables).", + {}, + async () => wrap(() => tools.exportDatabaseBackup())() + ); + + server.tool( + "get_database_stats", + "Get entity count statistics across all database tables.", + {}, + async () => wrap(() => tools.getDatabaseStats())() + ); + + return server; +} + +export async function runStdioServer() { + const server = createFormulationMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error("Formulation MCP Server running on stdio"); +} diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts new file mode 100644 index 0000000..f6a926b --- /dev/null +++ b/src/mcp/tools.ts @@ -0,0 +1,1063 @@ +import type { DatabaseSync } from "node:sqlite"; +import { + refreshSiteProjection, + saveRecipeStructure, + recipeQualityRows, + restoreArchivedItems as restoreArchivedItemsDb, + permanentlyDeleteArchivedItems as permanentlyDeleteArchivedItemsDb, + type QualityRecipe, +} from "../lib/database.ts"; +import { loadCatalogs } from "../lib/data.ts"; +import { exportDatabase } from "../lib/backup/export-database.ts"; +import { calculateCost, type CostResult } from "../lib/costing.ts"; +import { calculateNutrition, type NutritionResult } from "../lib/nutrition.ts"; +import { convert, convertWithIngredientMeasures } from "../lib/measurement.ts"; +import { + getInventoryCounts, + getInventoryCountDetail as getInvCountDetailRepo, + createInventoryCount as createInvCountRepo, + saveInventoryCountItems, + type InventoryCountDetail, +} from "../lib/repository/inventory-repository.ts"; +import { titleCase } from "../lib/format.ts"; +import type { Recipe, Ingredient, Unit, Equipment, PrepAction } from "../lib/types.ts"; + +function buildCatalogs() { + return loadCatalogs(); +} + +export function createMcpTools(getDb: () => DatabaseSync) { + return { + // ----------------------------------------------------------------------- + // 1. Search Recipes + // ----------------------------------------------------------------------- + searchRecipes(args: { query?: string; category?: string; tag?: string; limit?: number }) { + const catalogs = buildCatalogs(); + const q = (args.query ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); + let results = [...catalogs.recipes.values()] as Recipe[]; + + results = results.filter((r) => !(r as any).deleted_at); + + if (q) { + results = results.filter( + (r) => + r.title.toLowerCase().includes(q) || + r.id.toLowerCase().includes(q) || + (r.summary ?? "").toLowerCase().includes(q) + ); + } + if (args.category) { + results = results.filter((r) => r.categories.includes(args.category!)); + } + if (args.tag) { + results = results.filter((r) => (r.tags ?? []).includes(args.tag!)); + } + results = results.slice(0, limit); + + return results.map((r) => ({ + id: r.id, + title: r.title, + summary: r.summary ?? null, + categories: r.categories, + tags: r.tags, + yield: { + quantity: r.yield.amount.quantity, + unit_id: r.yield.amount.unit_id, + servings: r.yield.servings ?? null, + }, + component_count: r.components.length, + item_count: r.components.reduce((n, c) => n + c.items.length, 0), + step_count: r.steps.length, + })); + }, + + // ----------------------------------------------------------------------- + // 2. Get Recipe (with scaling) + // ----------------------------------------------------------------------- + getRecipe(args: { id: string; scale_factor?: number; target_yield?: number; target_yield_unit?: string }) { + const catalogs = buildCatalogs(); + const recipe = catalogs.recipes.get(args.id); + if (!recipe) throw new Error(`Recipe not found: ${args.id}`); + + let scaleFactor = 1; + if (args.scale_factor && args.scale_factor > 0) { + scaleFactor = args.scale_factor; + } else if (args.target_yield && args.target_yield > 0 && recipe.yield.amount.quantity > 0) { + if (args.target_yield_unit && args.target_yield_unit !== recipe.yield.amount.unit_id) { + try { + const converted = convert(args.target_yield, args.target_yield_unit, recipe.yield.amount.unit_id, catalogs.units); + scaleFactor = converted / recipe.yield.amount.quantity; + } catch { + scaleFactor = args.target_yield / recipe.yield.amount.quantity; + } + } else { + scaleFactor = args.target_yield / recipe.yield.amount.quantity; + } + } + + const ingredientNames = catalogs.ingredients; + + return { + id: recipe.id, + title: recipe.title, + summary: recipe.summary ?? null, + categories: recipe.categories, + tags: recipe.tags, + station: recipe.station ?? null, + yield: { + quantity: recipe.yield.amount.quantity * scaleFactor, + base_quantity: recipe.yield.amount.quantity, + unit_id: recipe.yield.amount.unit_id, + servings: recipe.yield.servings ? recipe.yield.servings * scaleFactor : null, + basis: recipe.yield.basis ?? null, + }, + scale_factor: scaleFactor, + scaling: recipe.scaling ?? null, + components: recipe.components.map((c) => ({ + id: c.id, + name: c.name, + notes: c.notes ?? [], + items: c.items.map((item) => { + const ref = item.reference; + const isSubrecipe = "recipe_id" in ref; + const subjectId = isSubrecipe ? (ref as any).recipe_id : (ref as any).ingredient_id; + const subject = isSubrecipe ? catalogs.recipes.get(subjectId) : ingredientNames.get(subjectId); + + return { + id: item.id, + ingredient_id: isSubrecipe ? undefined : subjectId, + subrecipe_id: isSubrecipe ? subjectId : undefined, + name: subject ? (isSubrecipe ? (subject as Recipe).title : titleCase((subject as Ingredient).name)) : subjectId, + is_subrecipe: isSubrecipe, + quantity: item.amount.quantity * scaleFactor, + base_quantity: item.amount.quantity, + unit_id: item.amount.unit_id, + percentage: item.percentage ?? null, + basis_member: item.basis_member ?? false, + optional: item.optional ?? false, + notes: item.notes ?? null, + prep: item.prep ?? [], + }; + }), + })), + steps: recipe.steps.map((s, i) => ({ + id: s.id, + order: i + 1, + instruction: s.instruction, + critical_control_point: s.critical_control_point ?? false, + equipment_ids: s.equipment_ids ?? [], + })), + equipment_ids: recipe.equipment_ids ?? [], + notes: recipe.notes ?? [], + shelf_life: recipe.shelf_life ?? null, + }; + }, + + // ----------------------------------------------------------------------- + // 3. Save Recipe + // ----------------------------------------------------------------------- + saveRecipe(args: { + id?: string; + title: string; + summary?: string | null; + yield_quantity: number; + yield_unit_id: string; + yield_servings?: number | null; + yield_basis?: string | null; + categories?: string[]; + tags?: string[]; + components: Array<{ + id?: string; + name?: string; + items: Array<{ + id?: string; + ingredient_id?: string; + subrecipe_id?: string; + quantity: number; + unit_id: string; + percentage?: number | null; + basis_member?: boolean; + optional?: boolean; + notes?: string | null; + }>; + }>; + steps: Array<{ + id?: string; + instruction: string; + equipment_ids?: string[]; + }>; + notes?: string[]; + }) { + const db = getDb(); + + let recipeId = args.id; + const isNew = !recipeId; + + if (!recipeId) { + const base = args.title + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "recipe"; + recipeId = base; + let suffix = 2; + while (db.prepare("SELECT 1 FROM recipes WHERE id = ?").get(recipeId)) { + recipeId = `${base}_${suffix++}`; + } + + db.prepare( + `INSERT INTO recipes ( + id, schema_version, save_version, title, summary, + categories_json, tags_json, yield_quantity, yield_unit_id, + yield_servings, yield_basis, notes_json, source_json + ) VALUES (?, 2, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}')` + ).run( + recipeId, + args.title, + args.summary ?? null, + JSON.stringify(args.categories ?? []), + JSON.stringify(args.tags ?? []), + args.yield_quantity, + args.yield_unit_id, + args.yield_servings ?? null, + args.yield_basis ?? "theoretical", + JSON.stringify(args.notes ?? []) + ); + + db.prepare("INSERT INTO recipe_components (recipe_id, id, position, name, notes_json) VALUES (?, 'main', 1, 'Main', '[]')").run(recipeId); + db.prepare("INSERT INTO recipe_steps (recipe_id, id, position, instruction, critical_control_point) VALUES (?, 'step_1', 1, 'Placeholder.', 0)").run(recipeId); + } + + const structure = { + save_version: isNew ? 0 : (db.prepare("SELECT save_version FROM recipes WHERE id = ?").get(recipeId) as any).save_version, + metadata: { + title: args.title, + yield_quantity: args.yield_quantity, + yield_unit_id: args.yield_unit_id, + yield_servings: args.yield_servings ?? null, + yield_basis: args.yield_basis ?? "theoretical", + tags: args.tags ?? [], + }, + components: args.components.map((c, ci) => ({ + id: c.id || `comp_${ci + 1}`, + name: c.name || "Main", + items: c.items.map((item, ii) => ({ + id: item.id || `item_${ci + 1}_${ii + 1}`, + ...(item.ingredient_id ? { ingredient_id: item.ingredient_id } : {}), + ...(item.subrecipe_id ? { subrecipe_id: item.subrecipe_id } : {}), + quantity: item.quantity, + unit_id: item.unit_id, + ...(item.percentage != null ? { percentage: item.percentage } : {}), + basis_member: item.basis_member ?? false, + optional: item.optional ?? false, + ...(item.notes ? { notes: item.notes } : {}), + prep: [], + })), + })), + steps: args.steps.map((s, si) => ({ + id: s.id || `step_${si + 1}`, + instruction: s.instruction, + equipment_ids: s.equipment_ids ?? [], + })), + }; + + saveRecipeStructure(db, recipeId, structure as any); + + return { + success: true, + recipe_id: recipeId, + created: isNew, + message: `Recipe '${args.title}' ${isNew ? "created" : "updated"} successfully.`, + }; + }, + + // ----------------------------------------------------------------------- + // 4. Delete / Archive Recipe + // ----------------------------------------------------------------------- + deleteRecipe(args: { id: string }) { + const db = getDb(); + const existing = db.prepare("SELECT title FROM recipes WHERE id = ? AND deleted_at IS NULL").get(args.id) as { title: string } | undefined; + if (!existing) throw new Error(`Recipe not found: ${args.id}`); + + const parent = db.prepare( + `SELECT r.title FROM recipe_items ri + JOIN recipes r ON r.id = ri.recipe_id + WHERE ri.subrecipe_id = ? AND r.deleted_at IS NULL + LIMIT 1` + ).get(args.id) as { title: string } | undefined; + + if (parent) { + throw new Error(`Cannot archive recipe '${existing.title}' because it is used as a sub-recipe in '${parent.title}'.`); + } + + db.prepare("UPDATE recipes SET deleted_at = datetime('now') WHERE id = ?").run(args.id); + try { refreshSiteProjection(db); } catch {} + + return { + success: true, + recipe_id: args.id, + message: `Recipe '${existing.title}' archived successfully.`, + }; + }, + + // ----------------------------------------------------------------------- + // 5. Recipe Quality Audit + // ----------------------------------------------------------------------- + auditRecipeQuality(): { summary: { total: number; clean: number; needs_attention: number }; recipes: QualityRecipe[] } { + const db = getDb(); + const rows = recipeQualityRows(db); + const clean = rows.filter( + (r) => r.placeholder_steps === 0 && r.unpriced_items === 0 && r.yield_basis !== null + ).length; + + return { + summary: { + total: rows.length, + clean, + needs_attention: rows.length - clean, + }, + recipes: rows, + }; + }, + + // ----------------------------------------------------------------------- + // 6. Calculate Recipe Cost + // ----------------------------------------------------------------------- + calculateRecipeCost(args: { recipe_id: string; currency?: string }): CostResult { + const catalogs = buildCatalogs(); + const recipe = catalogs.recipes.get(args.recipe_id); + if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); + + return calculateCost(recipe, { + recipes: catalogs.recipes, + ingredients: catalogs.ingredients, + units: catalogs.units, + purchaseItems: catalogs.purchaseItems, + prepActions: catalogs.prepActions, + }, args.currency ?? "USD"); + }, + + // ----------------------------------------------------------------------- + // 7. Calculate Recipe Nutrition + // ----------------------------------------------------------------------- + calculateRecipeNutrition(args: { recipe_id: string; serving_size_g?: number }): NutritionResult { + const catalogs = buildCatalogs(); + const recipe = catalogs.recipes.get(args.recipe_id); + if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`); + + return calculateNutrition(recipe, { + recipes: catalogs.recipes, + ingredients: catalogs.ingredients, + units: catalogs.units, + mappings: catalogs.sourceMappings, + }); + }, + + // ----------------------------------------------------------------------- + // 8. Search Ingredients + // ----------------------------------------------------------------------- + searchIngredients(args: { query?: string; category?: string; missing_cost?: boolean; limit?: number }) { + const catalogs = buildCatalogs(); + const q = (args.query ?? "").trim().toLowerCase(); + const limit = Math.min(Math.max(args.limit ?? 25, 1), 100); + + let results = [...catalogs.ingredients.values()] as Ingredient[]; + + if (q) { + results = results.filter( + (ing) => + ing.name.toLowerCase().includes(q) || + ing.id.toLowerCase().includes(q) || + (ing.aliases ?? []).some((a) => a.name.toLowerCase().includes(q)) + ); + } + if (args.category) { + results = results.filter((ing) => ing.categories.includes(args.category!)); + } + if (args.missing_cost) { + const purchasedIngredients = new Set( + [...catalogs.purchaseItems.values()] + .filter((pi) => pi.status === "active") + .map((pi) => pi.ingredient_id) + ); + results = results.filter((ing) => !purchasedIngredients.has(ing.id)); + } + + results = results.slice(0, limit); + + const recipeUsage = new Map(); + for (const recipe of catalogs.recipes.values()) { + for (const comp of recipe.components) { + for (const item of comp.items) { + if ("ingredient_id" in item.reference) { + recipeUsage.set(item.reference.ingredient_id, (recipeUsage.get(item.reference.ingredient_id) ?? 0) + 1); + } + } + } + } + + return results.map((ing) => ({ + id: ing.id, + name: titleCase(ing.name), + status: ing.status, + categories: ing.categories, + tags: ing.tags ?? [], + alias_count: (ing.aliases ?? []).length, + has_nutrition: (ing.nutrition_mapping_ids ?? []).length > 0, + has_density: (ing.density_measurements ?? []).length > 0, + has_cost: [...catalogs.purchaseItems.values()].some((pi) => pi.ingredient_id === ing.id && pi.status === "active"), + used_in_recipe_count: recipeUsage.get(ing.id) ?? 0, + })); + }, + + // ----------------------------------------------------------------------- + // 9. Get Ingredient Detail + // ----------------------------------------------------------------------- + getIngredient(args: { id: string }) { + const catalogs = buildCatalogs(); + const ing = catalogs.ingredients.get(args.id); + if (!ing) throw new Error(`Ingredient not found: ${args.id}`); + + const purchases = [...catalogs.purchaseItems.values()].filter( + (pi) => pi.ingredient_id === args.id + ); + + return { + id: ing.id, + name: titleCase(ing.name), + raw_name: ing.name, + status: ing.status, + categories: ing.categories, + tags: ing.tags ?? [], + aliases: ing.aliases ?? [], + density_measurements: (ing.density_measurements ?? []).map((d) => ({ + id: d.id, + mass: d.mass, + volume: d.volume, + state: d.state ?? null, + })), + measure_conversions: (ing.measure_conversions ?? []).map((c) => ({ + id: c.id, + from: c.from, + to: c.to, + state: c.state ?? null, + })), + prep_actions: ing.prep_actions ?? [], + nutrition_mapping_ids: ing.nutrition_mapping_ids ?? [], + purchase_items: purchases.map((p) => ({ + id: p.id, + name: p.name, + brand: p.brand ?? null, + status: p.status, + package_quantity: p.package.quantity, + package_unit_id: p.package.unit_id, + units_per_case: p.package.units_per_case ?? null, + latest_price: p.prices.length > 0 ? p.prices[p.prices.length - 1].amount : null, + currency: p.prices.length > 0 ? p.prices[p.prices.length - 1].currency : null, + })), + }; + }, + + // ----------------------------------------------------------------------- + // 10. Save Ingredient + // ----------------------------------------------------------------------- + saveIngredient(args: { + id?: string; + name: string; + description?: string | null; + categories?: string[]; + tags?: string[]; + aliases?: Array<{ name: string; kind?: string }>; + density?: { mass_quantity: number; mass_unit_id: string; volume_quantity: number; volume_unit_id: string }; + }) { + const db = getDb(); + let ingId = args.id; + const isNew = !ingId; + + if (!ingId) { + const base = args.name + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "ingredient"; + ingId = base; + let suffix = 2; + while (db.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(ingId)) { + ingId = `${base}_${suffix++}`; + } + } + + db.exec("BEGIN IMMEDIATE;"); + try { + db.prepare( + `INSERT INTO ingredients (id, schema_version, name, description, status, categories_json, tags_json, source_json) + VALUES (?, 2, ?, ?, 'active', ?, ?, '{}') + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + categories_json = excluded.categories_json, + tags_json = excluded.tags_json, + deleted_at = NULL` + ).run( + ingId, + args.name.trim(), + args.description?.trim() || null, + JSON.stringify(args.categories ?? []), + JSON.stringify(args.tags ?? []) + ); + + if (args.aliases) { + db.prepare("DELETE FROM ingredient_aliases WHERE ingredient_id = ?").run(ingId); + const aliasStmt = db.prepare("INSERT INTO ingredient_aliases (ingredient_id, name, kind) VALUES (?, ?, ?)"); + for (const a of args.aliases) { + if (a.name.trim()) aliasStmt.run(ingId, a.name.trim(), a.kind || "search"); + } + } + + if (args.density) { + db.prepare("DELETE FROM ingredient_density_measurements WHERE ingredient_id = ?").run(ingId); + db.prepare( + `INSERT INTO ingredient_density_measurements (ingredient_id, id, mass_quantity, mass_unit_id, volume_quantity, volume_unit_id, source_json) + VALUES (?, ?, ?, ?, ?, ?, '{}')` + ).run( + ingId, + `density_${ingId}`, + args.density.mass_quantity, + args.density.mass_unit_id, + args.density.volume_quantity, + args.density.volume_unit_id + ); + } + + db.exec("COMMIT;"); + } catch (err) { + db.exec("ROLLBACK;"); + throw err; + } + + try { refreshSiteProjection(db); } catch {} + + return { + success: true, + ingredient_id: ingId, + created: isNew, + message: `Ingredient '${args.name}' ${isNew ? "created" : "updated"} successfully.`, + }; + }, + + // ----------------------------------------------------------------------- + // 11. Delete / Archive Ingredient + // ----------------------------------------------------------------------- + deleteIngredient(args: { id: string }) { + const db = getDb(); + const existing = db.prepare("SELECT name FROM ingredients WHERE id = ? AND deleted_at IS NULL").get(args.id) as { name: string } | undefined; + if (!existing) throw new Error(`Ingredient not found: ${args.id}`); + + const inRecipe = db.prepare( + `SELECT r.title FROM recipe_items ri + JOIN recipes r ON r.id = ri.recipe_id + WHERE ri.ingredient_id = ? AND r.deleted_at IS NULL + LIMIT 1` + ).get(args.id) as { title: string } | undefined; + + if (inRecipe) { + throw new Error(`Cannot archive ingredient '${existing.name}' because it is used in active recipe '${inRecipe.title}'.`); + } + + db.prepare("UPDATE ingredients SET deleted_at = datetime('now') WHERE id = ?").run(args.id); + try { refreshSiteProjection(db); } catch {} + + return { + success: true, + ingredient_id: args.id, + message: `Ingredient '${existing.name}' archived successfully.`, + }; + }, + + // ----------------------------------------------------------------------- + // 12. Purchasing & Price Observations + // ----------------------------------------------------------------------- + listPurchaseItems(args: { ingredient_id?: string; status?: string; limit?: number }) { + const db = getDb(); + let query = ` + SELECT p.id, p.ingredient_id, i.name as ingredient_name, p.name, p.brand, + p.supplier_id, p.supplier_sku, p.status, p.package_quantity, p.package_unit_id, + p.units_per_case, p.usable_yield_factor + FROM purchase_items p + JOIN ingredients i ON i.id = p.ingredient_id + WHERE p.deleted_at IS NULL + `; + const params: any[] = []; + if (args.ingredient_id) { + query += " AND p.ingredient_id = ?"; + params.push(args.ingredient_id); + } + if (args.status) { + query += " AND p.status = ?"; + params.push(args.status); + } + query += " ORDER BY p.name LIMIT ?"; + params.push(Math.min(Math.max(args.limit ?? 50, 1), 200)); + + const rows = db.prepare(query).all(...params) as any[]; + const priceStmt = db.prepare("SELECT amount, currency, effective_at FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC LIMIT 1"); + + return rows.map((r) => { + const latestPrice = priceStmt.get(r.id) as any; + return { + ...r, + ingredient_name: titleCase(r.ingredient_name), + latest_price: latestPrice?.amount ?? null, + currency: latestPrice?.currency ?? null, + price_effective_at: latestPrice?.effective_at ?? null, + }; + }); + }, + + getPurchaseItem(args: { id: string }) { + const db = getDb(); + const item = db.prepare( + `SELECT p.*, i.name as ingredient_name + FROM purchase_items p + JOIN ingredients i ON i.id = p.ingredient_id + WHERE p.id = ? AND p.deleted_at IS NULL` + ).get(args.id) as any; + if (!item) throw new Error(`Purchase item not found: ${args.id}`); + + const prices = db.prepare( + "SELECT amount, currency, effective_at, source_json FROM price_observations WHERE purchase_item_id = ? ORDER BY effective_at DESC" + ).all(args.id) as any[]; + + return { + ...item, + ingredient_name: titleCase(item.ingredient_name), + price_history: prices.map((p) => ({ + amount: p.amount, + currency: p.currency, + effective_at: p.effective_at, + source: JSON.parse(p.source_json || "{}"), + })), + }; + }, + + savePurchaseItem(args: { + id?: string; + ingredient_id: string; + name: string; + brand?: string | null; + supplier_id?: string | null; + supplier_sku?: string | null; + gtin_upc?: string | null; + status?: "active" | "discontinued" | "unavailable"; + package_quantity: number; + package_unit_id: string; + units_per_case?: number; + usable_yield_factor?: number; + initial_price?: { amount: number; currency?: string; effective_at?: string }; + }) { + const db = getDb(); + const ing = db.prepare("SELECT 1 FROM ingredients WHERE id = ?").get(args.ingredient_id); + if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`); + + let pId = args.id; + const isNew = !pId; + if (!pId) { + const base = `pi_${args.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 30)}`; + pId = base; + let suffix = 2; + while (db.prepare("SELECT 1 FROM purchase_items WHERE id = ?").get(pId)) { + pId = `${base}_${suffix++}`; + } + } + + db.exec("BEGIN IMMEDIATE;"); + try { + db.prepare( + `INSERT INTO purchase_items ( + id, ingredient_id, name, brand, supplier_id, + supplier_sku, status, package_quantity, package_unit_id, + units_per_case, usable_yield_factor + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + brand = excluded.brand, + supplier_id = excluded.supplier_id, + supplier_sku = excluded.supplier_sku, + status = excluded.status, + package_quantity = excluded.package_quantity, + package_unit_id = excluded.package_unit_id, + units_per_case = excluded.units_per_case, + usable_yield_factor = excluded.usable_yield_factor, + deleted_at = NULL` + ).run( + pId, + args.ingredient_id, + args.name.trim(), + args.brand?.trim() || null, + args.supplier_id?.trim() || null, + args.supplier_sku?.trim() || null, + args.status || "active", + args.package_quantity, + args.package_unit_id, + args.units_per_case ?? 1, + args.usable_yield_factor ?? 1 + ); + + if (args.initial_price) { + const effectiveAt = args.initial_price.effective_at || new Date().toISOString().slice(0, 10); + const currency = args.initial_price.currency || "USD"; + db.prepare( + `INSERT OR REPLACE INTO price_observations (purchase_item_id, effective_at, currency, amount, source_json) + VALUES (?, ?, ?, ?, '{}')` + ).run(pId, effectiveAt, currency, args.initial_price.amount); + } + + db.exec("COMMIT;"); + } catch (err) { + db.exec("ROLLBACK;"); + throw err; + } + + try { refreshSiteProjection(db); } catch {} + + return { + success: true, + purchase_item_id: pId, + created: isNew, + message: `Purchase item '${args.name}' ${isNew ? "created" : "updated"} successfully.`, + }; + }, + + recordPriceObservation(args: { + purchase_item_id: string; + amount: number; + currency?: string; + effective_at?: string; + }) { + const db = getDb(); + const item = db.prepare("SELECT 1 FROM purchase_items WHERE id = ?").get(args.purchase_item_id); + if (!item) throw new Error(`Unknown purchase item: ${args.purchase_item_id}`); + + const effectiveAt = args.effective_at || new Date().toISOString().slice(0, 10); + const currency = args.currency || "USD"; + + db.prepare( + `INSERT OR REPLACE INTO price_observations (purchase_item_id, effective_at, currency, amount, source_json) + VALUES (?, ?, ?, ?, '{}')` + ).run(args.purchase_item_id, effectiveAt, currency, args.amount); + + try { refreshSiteProjection(db); } catch {} + + return { + success: true, + purchase_item_id: args.purchase_item_id, + amount: args.amount, + currency, + effective_at: effectiveAt, + message: `Price recorded for '${args.purchase_item_id}': ${args.amount} ${currency}`, + }; + }, + + // ----------------------------------------------------------------------- + // 13. Convert Units + // ----------------------------------------------------------------------- + convertUnits(args: { ingredient_id?: string; quantity: number; from_unit: string; to_unit: string }) { + const catalogs = buildCatalogs(); + const units = catalogs.units; + + const fromUnit = units.get(args.from_unit); + const toUnit = units.get(args.to_unit); + if (!fromUnit) throw new Error(`Unknown unit: ${args.from_unit}`); + if (!toUnit) throw new Error(`Unknown unit: ${args.to_unit}`); + + let result: number; + let method: string; + + if (fromUnit.dimension === toUnit.dimension) { + result = convert(args.quantity, args.from_unit, args.to_unit, units); + method = "dimension_conversion"; + } else if (args.ingredient_id) { + const ing = catalogs.ingredients.get(args.ingredient_id); + if (!ing) throw new Error(`Unknown ingredient: ${args.ingredient_id}`); + const converted = convertWithIngredientMeasures( + { quantity: args.quantity, unit_id: args.from_unit }, + args.to_unit, + ing, + units + ); + result = converted.quantity; + method = "ingredient_measure_conversion"; + } else { + throw new Error( + `Cannot convert ${fromUnit.dimension} to ${toUnit.dimension} without an ingredient_id for density lookup.` + ); + } + + return { + from: { quantity: args.quantity, unit_id: args.from_unit }, + to: { quantity: result, unit_id: args.to_unit }, + ingredient_id: args.ingredient_id ?? null, + method, + }; + }, + + // ----------------------------------------------------------------------- + // 14. Reference Catalogs: Units, Equipment, Prep Actions + // ----------------------------------------------------------------------- + listUnits(args?: { dimension?: string; system?: string }): Unit[] { + const catalogs = buildCatalogs(); + let units = [...catalogs.units.values()]; + if (args?.dimension) units = units.filter((u) => u.dimension === args.dimension); + if (args?.system) units = units.filter((u) => u.system === args.system); + return units; + }, + + listEquipment(args?: { category?: string }): Equipment[] { + const catalogs = buildCatalogs(); + let items = [...catalogs.equipment.values()]; + if (args?.category) items = items.filter((e) => e.category === args.category); + return items; + }, + + listPrepActions(): PrepAction[] { + const catalogs = buildCatalogs(); + return [...catalogs.prepActions.values()]; + }, + + // ----------------------------------------------------------------------- + // 15. Inventory Counts + // ----------------------------------------------------------------------- + listInventoryCounts(args: { status?: "all" | "open" | "completed" }) { + const db = getDb(); + let counts = getInventoryCounts(db); + if (args.status && args.status !== "all") { + counts = counts.filter((c) => c.status === args.status); + } + return counts; + }, + + getInventoryCount(args: { id: string }): InventoryCountDetail { + const db = getDb(); + const detail = getInvCountDetailRepo(db, args.id); + if (!detail) throw new Error(`Inventory count not found: ${args.id}`); + return detail; + }, + + createInventoryCountSession(args: { title: string; counted_at: string; notes?: string; prepopulate?: boolean }) { + const db = getDb(); + const countId = createInvCountRepo(db, args); + return { + success: true, + count_id: countId, + message: `Inventory count '${args.title}' created.`, + }; + }, + + updateInventoryCount(args: { + count_id: string; + status?: "open" | "completed"; + notes?: string; + items?: Array<{ ingredient_id: string; location_id?: string | null; quantity: number; unit_id: string }>; + }) { + const db = getDb(); + const existing = getInvCountDetailRepo(db, args.count_id); + if (!existing) throw new Error(`Inventory count not found: ${args.count_id}`); + + if (args.notes !== undefined) { + db.prepare("UPDATE inventory_counts SET notes = ? WHERE id = ?").run(args.notes || null, args.count_id); + } + + if (args.items) { + saveInventoryCountItems( + db, + args.count_id, + args.items.map((i) => ({ + location_id: i.location_id ?? null, + ingredient_id: i.ingredient_id, + quantity: i.quantity, + unit_id: i.unit_id, + })), + args.status + ); + } else if (args.status) { + db.prepare("UPDATE inventory_counts SET status = ? WHERE id = ?").run(args.status, args.count_id); + } + + return { + success: true, + count_id: args.count_id, + message: `Inventory count '${args.count_id}' updated.`, + }; + }, + + // ----------------------------------------------------------------------- + // 16. Recipe Books / Collections + // ----------------------------------------------------------------------- + listRecipeBooks() { + const db = getDb(); + const rows = db.prepare( + `SELECT c.id, c.name, c.description, + COUNT(cr.recipe_id) as recipe_count + FROM collections c + LEFT JOIN collection_recipes cr ON cr.collection_id = c.id + WHERE c.deleted_at IS NULL + GROUP BY c.id + ORDER BY c.name` + ).all() as any[]; + + return rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + recipe_count: r.recipe_count, + })); + }, + + getRecipeBook(args: { id: string }) { + const db = getDb(); + const book = db.prepare("SELECT id, name, description FROM collections WHERE id = ? AND deleted_at IS NULL").get(args.id) as any; + if (!book) throw new Error(`Recipe book not found: ${args.id}`); + + const recipes = db.prepare( + `SELECT r.id, r.title, r.summary, cr.position + FROM collection_recipes cr + JOIN recipes r ON r.id = cr.recipe_id + WHERE cr.collection_id = ? AND r.deleted_at IS NULL + ORDER BY cr.position, r.title` + ).all(args.id) as any[]; + + return { + id: book.id, + name: book.name, + description: book.description, + recipe_count: recipes.length, + recipes: recipes.map((r) => ({ + id: r.id, + title: r.title, + summary: r.summary, + position: r.position, + })), + }; + }, + + saveRecipeBook(args: { id?: string; name: string; description?: string | null; recipe_ids?: string[] }) { + const db = getDb(); + let bookId = args.id; + const isNew = !bookId; + + if (!bookId) { + const base = args.name + .toLowerCase() + .normalize("NFKD") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") || "book"; + bookId = base; + let suffix = 2; + while (db.prepare("SELECT 1 FROM collections WHERE id = ?").get(bookId)) { + bookId = `${base}_${suffix++}`; + } + } + + db.exec("BEGIN IMMEDIATE;"); + try { + db.prepare( + `INSERT INTO collections (id, name, description, source_json) + VALUES (?, ?, ?, '{}') + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + deleted_at = NULL` + ).run(bookId, args.name.trim(), args.description?.trim() || null); + + if (args.recipe_ids) { + db.prepare("DELETE FROM collection_recipes WHERE collection_id = ?").run(bookId); + const insert = db.prepare("INSERT INTO collection_recipes (collection_id, recipe_id, position) VALUES (?, ?, ?)"); + args.recipe_ids.forEach((rId, idx) => { + insert.run(bookId, rId, idx + 1); + }); + } + + db.exec("COMMIT;"); + } catch (err) { + db.exec("ROLLBACK;"); + throw err; + } + + try { refreshSiteProjection(db); } catch {} + + return { + success: true, + book_id: bookId, + created: isNew, + message: `Recipe book '${args.name}' ${isNew ? "created" : "updated"} successfully.`, + }; + }, + + // ----------------------------------------------------------------------- + // 17. Archive & Restore Management + // ----------------------------------------------------------------------- + listArchivedItems() { + const db = getDb(); + const recipes = db.prepare("SELECT id, title as name, deleted_at, 'recipe' as type FROM recipes WHERE deleted_at IS NOT NULL").all(); + const ingredients = db.prepare("SELECT id, name, deleted_at, 'ingredient' as type FROM ingredients WHERE deleted_at IS NOT NULL").all(); + const purchases = db.prepare("SELECT id, name, deleted_at, 'purchase' as type FROM purchase_items WHERE deleted_at IS NOT NULL").all(); + const collections = db.prepare("SELECT id, name, deleted_at, 'book' as type FROM collections WHERE deleted_at IS NOT NULL").all(); + const counts = db.prepare("SELECT id, title as name, deleted_at, 'inventory' as type FROM inventory_counts WHERE deleted_at IS NOT NULL").all(); + + return { + total: recipes.length + ingredients.length + purchases.length + collections.length + counts.length, + recipes, + ingredients, + purchases, + collections, + inventory_counts: counts, + }; + }, + + restoreArchived(args: { items: Array<{ id: string; type: string }> }) { + const db = getDb(); + restoreArchivedItemsDb(db, args.items); + return { + success: true, + restored_count: args.items.length, + message: `Restored ${args.items.length} items successfully.`, + }; + }, + + permanentlyDeleteArchived(args: { items: Array<{ id: string; type: string }> }) { + const db = getDb(); + permanentlyDeleteArchivedItemsDb(db, args.items); + return { + success: true, + deleted_count: args.items.length, + message: `Permanently deleted ${args.items.length} items.`, + }; + }, + + // ----------------------------------------------------------------------- + // 18. Database Export & Stats + // ----------------------------------------------------------------------- + exportDatabaseBackup() { + const db = getDb(); + return exportDatabase(db); + }, + + getDatabaseStats() { + const db = getDb(); + const count = (table: string) => (db.prepare(`SELECT count(*) as c FROM ${table}`).get() as any).c; + return { + recipes: count("recipes"), + ingredients: count("ingredients"), + units: count("units"), + equipment: count("equipment"), + prep_actions: count("prep_actions"), + purchase_items: count("purchase_items"), + price_observations: count("price_observations"), + collections: count("collections"), + inventory_locations: count("inventory_locations"), + inventory_counts: count("inventory_counts"), + }; + }, + }; +}