From 551bdc4104d9020ca9176bb2a993670fe66efc93 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Fri, 14 Aug 2026 18:24:33 -0500 Subject: [PATCH] clarify data ownership --- .gitignore | 6 +-- README.md | 22 ++++++-- docs/agent-handoff.md | 54 +++++++++++++++++++ docs/culinary-data-model.md | 22 +++++--- docs/local-application.md | 27 +++++++++- package.json | 1 + scripts/db-backup.mjs | 33 ++++++++++++ scripts/lib/site-projection.mjs | 4 +- src/application/pages/app/index.astro | 7 +-- .../pages/app/ingredients/[id].astro | 6 +-- src/application/pages/app/recipes/[id].astro | 2 +- src/components/RecipeCalculator.tsx | 5 +- src/lib/format.test.ts | 13 ++++- src/lib/format.ts | 11 ++++ 14 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 docs/agent-handoff.md create mode 100644 scripts/db-backup.mjs diff --git a/.gitignore b/.gitignore index aafaabf..13d907f 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,5 @@ Thumbs.db /ui-reference*.png /screenshots/ -# Local application databases and SQLite sidecars -/var/*.sqlite -/var/*.sqlite-shm -/var/*.sqlite-wal +# Local application databases, backups, logs, and SQLite sidecars +/var/ diff --git a/README.md b/README.md index 0488315..e694dfa 100644 --- a/README.md +++ b/README.md @@ -44,17 +44,33 @@ 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. +All normal recipe, ingredient, nutrition-mapping, and purchasing changes must +be written to SQLite through the application or its validated database +functions. This rule also applies to automated and AI-assisted edits. Do not +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: ```bash npm run db:reset ``` -This command replaces an existing local database. There is intentionally no -legacy upgrade chain. YAML under `culinary/` is retained as portable seed and -interchange data; normal edits in the management application write to SQLite. +**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. + +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). ## Development diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md new file mode 100644 index 0000000..cb04328 --- /dev/null +++ b/docs/agent-handoff.md @@ -0,0 +1,54 @@ +# Agent handoff + +## Repository state + +Development happens on `dev`; `master` is the deployable integration branch. +Use Node.js 22 or newer and install dependencies with `npm ci`. + +SQLite is the canonical writable store. YAML in `culinary/` is portable seed and +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 + +```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. + +## Transfer the latest application data + +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: + +```sh +npm run db:backup -- /safe/transfer/recipe-book.sqlite +``` + +Place the transferred file at `var/recipe-book.sqlite` on the receiving machine. +The backup command uses SQLite's online backup API, includes committed WAL data, +and refuses to overwrite an existing destination. + +## Validate a change + +```sh +scripts/validate-content +npm run check:app +npm test +npm run build:app +git diff --check +``` + +The application supports a read-only deployment with +`FORMULATION_READ_ONLY=true`. Ingredient bulk parsing additionally accepts +`FORMULATION_OLLAMA_URL` and `FORMULATION_INGREDIENT_PARSER_MODEL`; USDA imports +read `USDA_FDC_API_KEY` from the environment. diff --git a/docs/culinary-data-model.md b/docs/culinary-data-model.md index eed6006..8c0a302 100644 --- a/docs/culinary-data-model.md +++ b/docs/culinary-data-model.md @@ -13,10 +13,11 @@ current canonical state. Names used for search or display belong in ingredient ## Canonical and derived boundaries -Canonical records live under `culinary/`. They contain authored or observed +Canonical writable records live in SQLite. They include authored or observed facts: recipes, ingredients, measurements, provenance, suppliers, packages, -and price observations. Projections may later be generated for another -application or database, but they are never canonical. +and price observations. YAML under `culinary/` is portable seed/interchange +data, while generated site projections and exports are downstream products. +Neither is an independently writable source of truth. Derived recipe records contain reproducible nutrition, allergen rollups, and costs. They identify the recipe, calculation version, calculation time, and an @@ -129,7 +130,14 @@ truth. Recipes without authored instructions contain one explicit TODO step. Formula-only conversions use a nominal 100 g basis and a theoretical yield until those values are replaced by observed production data. -Astro reads a generated, read-only SQLite projection and produces static recipe -pages. Interactive calculators are small Preact islands supplied with resolved, -typed recipe data. Astro and Preact remain presentation consumers; culinary -calculations and editing originate in the database and shared calculation tools. +Application and agent changes must use validated domain save functions and +SQLite transactions. Direct SQL is reserved for schema-aware maintenance where +no domain operation exists. Reset/import commands flow from YAML into SQLite and +therefore overwrite the current store; export commands flow from SQLite into a +portable representation. + +Astro reads SQLite through the application data layer. Generated projections +support read-only presentation, and interactive calculators are Preact islands +supplied with resolved, typed recipe data. Astro, Preact, and projections remain +presentation consumers; culinary calculations and editing originate in SQLite +and shared domain tools. diff --git a/docs/local-application.md b/docs/local-application.md index b98f8e9..49f74e9 100644 --- a/docs/local-application.md +++ b/docs/local-application.md @@ -4,6 +4,27 @@ The local application uses SQLite as its canonical data store. YAML remains a portable import/export format, but normal application saves do not modify it. Derived nutrition and cost are still calculated rather than stored. +## Source-of-truth rule + +- SQLite is the only writable source of truth for a running installation. +- Humans should edit through the management application. +- Automation and AI agents should call validated application commands or domain + save functions such as `saveRecipeStructure()`. +- Agents should not edit YAML to change live data and should not issue + unrestricted SQL when a domain operation exists. +- Generated site projections and exports are downstream products of SQLite. + +A safe automated recipe change follows this flow: + +```text +agent request + -> validate recipe structure and references + -> domain save function + -> SQLite transaction + -> refresh derived projection + -> 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: @@ -24,5 +45,7 @@ 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. -`npm run db:import:yaml` and `npm run db:reset` both replace the database from -portable data and are intended for initial setup or an explicit restore. +`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. diff --git a/package.json b/package.json index f38d99b..a6ca758 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "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" }, diff --git a/scripts/db-backup.mjs b/scripts/db-backup.mjs new file mode 100644 index 0000000..3d78cd4 --- /dev/null +++ b/scripts/db-backup.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { backup, DatabaseSync } from "node:sqlite"; + +const source = path.resolve(process.cwd(), "var", "recipe-book.sqlite"); +const requestedTarget = process.argv[2]; + +if (!requestedTarget) { + throw new Error("Usage: npm run db:backup -- /path/to/recipe-book.sqlite"); +} +if (!fs.existsSync(source)) { + throw new Error(`Database not found: ${source}`); +} + +const target = path.resolve(requestedTarget); +if (target === source) { + throw new Error("Backup target must differ from the live database."); +} +if (fs.existsSync(target)) { + throw new Error(`Refusing to overwrite existing backup: ${target}`); +} + +fs.mkdirSync(path.dirname(target), { recursive: true }); +const database = new DatabaseSync(source, { readOnly: true }); +try { + await backup(database, target); +} finally { + database.close(); +} + +console.log(`Backed up ${source} to ${target}`); diff --git a/scripts/lib/site-projection.mjs b/scripts/lib/site-projection.mjs index d87f77c..561b10e 100644 --- a/scripts/lib/site-projection.mjs +++ b/scripts/lib/site-projection.mjs @@ -3,6 +3,8 @@ import path from "node:path"; const json = (value, fallback) => { try { return JSON.parse(value); } catch { return fallback; } }; +export const titleCase = (input) => input.split(/\s+/).map((word) => { const index = word.search(/\p{L}/u); return index === -1 ? word : word.slice(0, index) + word[index].toLocaleUpperCase() + word.slice(index + 1); }).join(" "); + export function createSiteProjection(database) { const units = database.prepare("SELECT * FROM units ORDER BY id").all().map((row) => ({ schema_version:2,id:row.id,name:row.name,symbol:row.symbol,dimension:row.dimension,system:row.system,...(row.base_unit_id?{base_conversion:{base_unit_id:row.base_unit_id,factor:row.factor,...(row.offset!=null?{offset:row.offset}:{})}}:{}) })); const aliasQuery=database.prepare("SELECT name,kind FROM ingredient_aliases WHERE ingredient_id=? ORDER BY name"); @@ -12,7 +14,7 @@ export function createSiteProjection(database) { const mappingIdsQuery=database.prepare("SELECT id,mapping_type FROM source_mappings WHERE subject_type='ingredient' AND subject_id=? AND status='reviewed' ORDER BY id"); const ingredients = database.prepare("SELECT * FROM ingredients ORDER BY id").all().map((row) => { const mappings=mappingIdsQuery.all(row.id), source=json(row.source_json,"{}"); - return { schema_version:row.schema_version,id:row.id,name:row.name,...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]), + return { schema_version:row.schema_version,id:row.id,name:titleCase(row.name),...(row.description?{description:row.description}:{}),status:row.status,categories:json(row.categories_json,[]),tags:json(row.tags_json,[]), aliases:aliasQuery.all(row.id), density_measurements:densityQuery.all(row.id).map((value)=>({id:value.id,mass:{quantity:value.mass_quantity,unit_id:value.mass_unit_id},volume:{quantity:value.volume_quantity,unit_id:value.volume_unit_id},...(value.temperature_c!=null?{temperature_c:value.temperature_c}:{}),...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})), measure_conversions:conversionQuery.all(row.id).map((value)=>({id:value.id,from:{quantity:value.from_quantity,unit_id:value.from_unit_id},to:{quantity:value.to_quantity,unit_id:value.to_unit_id},...(value.state?{state:value.state}:{}),source:json(value.source_json,{})})), diff --git a/src/application/pages/app/index.astro b/src/application/pages/app/index.astro index c19ce3b..f2e593e 100644 --- a/src/application/pages/app/index.astro +++ b/src/application/pages/app/index.astro @@ -4,6 +4,7 @@ import BaseLayout from "../../../layouts/BaseLayout.astro"; import EntityDirectory from "../../../components/EntityDirectory"; import { openDatabase } from "../../../lib/database"; import { readOnlyMode } from "../../../lib/runtime"; +import { titleCase } from "../../../lib/format"; const database = openDatabase(); if (!database) return new Response("Database unavailable", { status:503 }); @@ -41,12 +42,12 @@ const tabs=[ ]; const allSearchResults=normalizedQuery ? [ ...recipes.filter((item)=>`${item.title} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"recipe",label:"Recipe",icon:"▦",name:item.title,detail:`${item.yield_quantity} ${item.yield_unit_id}`,href:`/app/recipes/${item.id}/`})), - ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:item.name,detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), + ...ingredients.filter((item)=>`${item.name} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"ingredient",label:"Ingredient",icon:"●",name:titleCase(item.name),detail:`Used in ${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:`/app/ingredients/${item.id}/`})), ...books.filter((item)=>`${item.name} ${item.description??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"book",label:"Recipe book",icon:"▣",name:item.name,detail:`${item.recipe_count} ${item.recipe_count===1?"recipe":"recipes"}`,href:"/app/?type=book"})), - ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:item.ingredient_name,href:`/app/ingredients/${item.ingredient_id}/#costs`})), + ...purchases.filter((item)=>`${item.name} ${item.ingredient_name} ${item.supplier_id??""} ${item.id}`.toLocaleLowerCase().includes(normalizedQuery)).map((item)=>({kind:"purchase",label:"Purchase item",icon:"$",name:item.name,detail:titleCase(item.ingredient_name),href:`/app/ingredients/${item.ingredient_id}/#costs`})), ].sort((a,b)=>a.name.localeCompare(b.name)):[]; const searchResults=filteringSearchTypes?allSearchResults.filter((result)=>selectedSearchTypes.includes(result.kind)):allSearchResults; -const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"})); +const ingredientRows=filteredIngredients.map(item=>({id:item.id,name:titleCase(item.name),href:`/app/ingredients/${item.id}/`,kind:"ingredient" as const,icon:"●"})); const recipeRows=filteredRecipes.map(item=>({id:item.id,name:item.title,href:`/app/recipes/${item.id}/`,kind:"recipe" as const,icon:"▦"})); const bookRows=books.map(item=>({id:item.id,name:item.name,href:`/app/recipe-books/${item.id}/`,kind:"book" as const,icon:"▣"})); const purchaseRows=purchases.map(item=>({id:item.id,name:item.name,href:`/app/ingredients/${item.ingredient_id}/#costs`,kind:"purchase" as const,icon:"$"})); diff --git a/src/application/pages/app/ingredients/[id].astro b/src/application/pages/app/ingredients/[id].astro index 125e7f1..6a51087 100644 --- a/src/application/pages/app/ingredients/[id].astro +++ b/src/application/pages/app/ingredients/[id].astro @@ -4,7 +4,7 @@ import BaseLayout from "../../../../layouts/BaseLayout.astro"; import { openDatabase, refreshSiteProjection } from "../../../../lib/database"; import { readOnlyMode } from "../../../../lib/runtime"; import { bestUsdaPortions, fetchUsdaFood, usdaNutrition } from "../../../../lib/usda"; -import { number } from "../../../../lib/format"; +import { number, titleCase } from "../../../../lib/format"; import PurchaseItemForm from "../../../../components/PurchaseItemForm.astro"; import DetailUtility from "../../../../components/DetailUtility.astro"; const id = Astro.params.id!; @@ -129,10 +129,10 @@ const prepDisplay = prep.map((row) => { }); database.close(); --- - +
-

← Ingredients

{editing?:

{ingredient.name}

}
{!readOnlyMode&&
{editing?:✎ Edit}{editing&&
}
}
+

← Ingredients

{editing?:

{titleCase(ingredient.name)}

}
{!readOnlyMode&&
{editing?:✎ Edit}{editing&&
}
}
{message&&
{message}
}{error&&
{error}
}
diff --git a/src/application/pages/app/recipes/[id].astro b/src/application/pages/app/recipes/[id].astro index 999eba3..9ccbd52 100644 --- a/src/application/pages/app/recipes/[id].astro +++ b/src/application/pages/app/recipes/[id].astro @@ -170,7 +170,7 @@ const saved = Astro.url.searchParams.get("saved") === "1";

UoM Equivalency

Define how this finished recipe converts between weight, volume, and portions.

{recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
)}
=

Additional Details

Shelf Life
- :<>

Prep Method {domainRecipe.steps.length}

    {domainRecipe.steps.map(step=>
  1. {step.order}.{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=>
    {entry.media_type==="image"?{entry.caption??""}/:
    )}
  2. )}

UoM Equivalency

{recipeConversions.length?recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
):

No recipe-level equivalencies have been defined.

}
{additional.cover_media_url&&
}

Additional details

{additional.station&&

Station{additional.station}

}{shelfLife&&

Shelf life{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}

}{JSON.parse(additional.notes_json??"[]").length>0&&
    {JSON.parse(additional.notes_json).map((note:string)=>
  • {note}
  • )}
}
} + :<>

Prep Method {domainRecipe.steps.length}

    {domainRecipe.steps.map(step=>
  1. {step.order}.{step.instruction}{media.filter(entry=>entry.step_id===step.id).map(entry=>
    {entry.media_type==="image"?{entry.caption??""}/:
    )}
  2. )}

UoM Equivalency

{recipeConversions.length?recipeConversions.map(x=>
{x.from_quantity} {x.from_unit_id}={x.to_quantity} {x.to_unit_id}{x.notes}
):

No recipe-level equivalencies have been defined.

}
{additional.cover_media_url&&
}

Additional details

{additional.station&&

Station{additional.station}

}{shelfLife&&

Shelf life{shelfLife.duration.quantity} {shelfLife.duration.unit_id}{shelfLife.storage_condition?` · ${shelfLife.storage_condition}`:""}

}{JSON.parse(additional.notes_json??"[]").length>0&&
    {JSON.parse(additional.notes_json).map((note:string)=>
  • {note}
  • )}
}
}
{!editing&&} {editing&&