feat(cli): add local domain command surface
This commit is contained in:
1 parent
f50b182c17
commit
01e8dac359
6 files changed
+328
-14
No files matched your search
@@ -2,6 +2,9 @@
|
||||
"name": "formulation",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"formulation": "scripts/formulation.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0",
|
||||
"npm": ">=9.6.5"
|
||||
@@ -19,6 +22,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",
|
||||
"cli": "node scripts/formulation.mjs",
|
||||
"db:backup": "node scripts/backup.mjs export",
|
||||
"db:restore": "node scripts/backup.mjs import",
|
||||
"db:reset": "node scripts/backup.mjs import data/recipe-book-backup.json",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { runCli } from "../src/cli/run.ts";
|
||||
|
||||
process.exitCode = await runCli(process.argv.slice(2));
|
||||
@@ -0,0 +1,49 @@
|
||||
export type CommandDefinition = {
|
||||
summary: string;
|
||||
method?: string;
|
||||
positionals?: string[];
|
||||
options?: string[];
|
||||
required?: string[];
|
||||
mutation?: boolean;
|
||||
destructive?: boolean;
|
||||
custom?: "export" | "import" | "validate" | "diff";
|
||||
};
|
||||
|
||||
export const commands: Record<string, CommandDefinition> = {
|
||||
"recipe list": { summary: "List or search recipes", method: "searchRecipes", options: ["query", "category", "tag", "limit"] },
|
||||
"recipe get": { summary: "Get a recipe, optionally scaled", method: "getRecipe", positionals: ["id"], options: ["scale_factor", "target_yield", "target_yield_unit"], required: ["id"] },
|
||||
"recipe save": { summary: "Create or update a recipe from JSON", method: "saveRecipe", positionals: ["id"], mutation: true },
|
||||
"recipe archive": { summary: "Archive a recipe", method: "deleteRecipe", positionals: ["id"], required: ["id"], mutation: true },
|
||||
"recipe quality": { summary: "Audit recipe data quality", method: "auditRecipeQuality" },
|
||||
"recipe cost": { summary: "Calculate recipe cost", method: "calculateRecipeCost", positionals: ["recipe_id"], options: ["currency"], required: ["recipe_id"] },
|
||||
"recipe nutrition": { summary: "Calculate recipe nutrition", method: "calculateRecipeNutrition", positionals: ["recipe_id"], options: ["serving_size_g"], required: ["recipe_id"] },
|
||||
"ingredient list": { summary: "List or search ingredients", method: "searchIngredients", options: ["query", "category", "missing_cost", "limit"] },
|
||||
"ingredient get": { summary: "Get ingredient details", method: "getIngredient", positionals: ["id"], required: ["id"] },
|
||||
"ingredient save": { summary: "Create or update an ingredient from JSON", method: "saveIngredient", positionals: ["id"], mutation: true },
|
||||
"ingredient archive": { summary: "Archive an ingredient", method: "deleteIngredient", positionals: ["id"], required: ["id"], mutation: true },
|
||||
"purchase list": { summary: "List purchase items", method: "listPurchaseItems", options: ["ingredient_id", "status", "limit"] },
|
||||
"purchase get": { summary: "Get a purchase item", method: "getPurchaseItem", positionals: ["id"], required: ["id"] },
|
||||
"purchase save": { summary: "Create or update a purchase item from JSON", method: "savePurchaseItem", positionals: ["id"], mutation: true },
|
||||
"purchase price": { summary: "Record a price observation", method: "recordPriceObservation", positionals: ["purchase_item_id"], options: ["amount", "currency", "effective_at"], required: ["purchase_item_id", "amount"], mutation: true },
|
||||
"unit list": { summary: "List units", method: "listUnits", options: ["dimension", "system"] },
|
||||
"unit convert": { summary: "Convert a quantity", method: "convertUnits", positionals: ["quantity", "from_unit", "to_unit"], options: ["ingredient_id"], required: ["quantity", "from_unit", "to_unit"] },
|
||||
"equipment list": { summary: "List equipment", method: "listEquipment", options: ["category"] },
|
||||
"prep-action list": { summary: "List preparation actions", method: "listPrepActions" },
|
||||
"inventory list": { summary: "List inventory counts", method: "listInventoryCounts", options: ["status"] },
|
||||
"inventory get": { summary: "Get an inventory count", method: "getInventoryCount", positionals: ["id"], required: ["id"] },
|
||||
"inventory create": { summary: "Create an inventory count from JSON", method: "createInventoryCountSession", options: ["title", "counted_at", "notes", "prepopulate"], required: ["title", "counted_at"], mutation: true },
|
||||
"inventory update": { summary: "Update an inventory count from JSON", method: "updateInventoryCount", positionals: ["count_id"], required: ["count_id"], mutation: true },
|
||||
"book list": { summary: "List recipe books", method: "listRecipeBooks" },
|
||||
"book get": { summary: "Get a recipe book", method: "getRecipeBook", positionals: ["id"], required: ["id"] },
|
||||
"book save": { summary: "Create or update a recipe book from JSON", method: "saveRecipeBook", positionals: ["id"], mutation: true },
|
||||
"archive list": { summary: "List archived entities", method: "listArchivedItems" },
|
||||
"archive restore": { summary: "Restore archived entities from JSON", method: "restoreArchived", mutation: true },
|
||||
"archive purge": { summary: "Permanently delete archived entities", method: "permanentlyDeleteArchived", mutation: true, destructive: true },
|
||||
"db stats": { summary: "Show database record counts", method: "getDatabaseStats" },
|
||||
"db export": { summary: "Export a validated backup bundle", custom: "export" },
|
||||
"db validate": { summary: "Validate a backup bundle", custom: "validate" },
|
||||
"db import": { summary: "Import a backup bundle", custom: "import", options: ["mode"], mutation: true, destructive: true },
|
||||
"db diff": { summary: "Compare the database with a backup bundle", custom: "diff" },
|
||||
};
|
||||
|
||||
export const globalOptions = new Set(["database", "input", "output", "compact", "dry_run", "yes", "help"]);
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { backup, DatabaseSync } from "node:sqlite";
|
||||
import { commands, globalOptions, type CommandDefinition } from "./commands.ts";
|
||||
import { createDomainTools } from "../lib/domain-tools.ts";
|
||||
import { databasePath as defaultDatabasePath, refreshSiteProjection } from "../lib/database.ts";
|
||||
import { exportDatabase } from "../lib/backup/export-database.ts";
|
||||
import { importDatabase } from "../lib/backup/import-database.ts";
|
||||
import { validateBackupBundle } from "../lib/backup/validate-backup.ts";
|
||||
|
||||
export const CLI_VERSION = "1.0.0";
|
||||
export const EXIT_USAGE = 2;
|
||||
export const EXIT_DOMAIN = 3;
|
||||
export const EXIT_SYSTEM = 4;
|
||||
|
||||
type Io = {
|
||||
stdout: (text: string) => void;
|
||||
stderr: (text: string) => void;
|
||||
readStdin: () => string;
|
||||
};
|
||||
|
||||
type Parsed = {
|
||||
command: string;
|
||||
definition: CommandDefinition;
|
||||
values: Record<string, unknown>;
|
||||
compact: boolean;
|
||||
dryRun: boolean;
|
||||
yes: boolean;
|
||||
databasePath: string;
|
||||
inputPath?: string;
|
||||
outputPath?: string;
|
||||
};
|
||||
|
||||
class CliError extends Error {
|
||||
readonly exitCode: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(message: string, exitCode = EXIT_USAGE, code = "USAGE_ERROR") {
|
||||
super(message);
|
||||
this.exitCode = exitCode;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeOption = (name: string) => name.replace(/^--/, "").replaceAll("-", "_");
|
||||
|
||||
function coerce(value: string): unknown {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
if (value === "null") return null;
|
||||
if (/^-?(?:\d+\.?\d*|\.\d+)$/.test(value)) return Number(value);
|
||||
if (/^[\[{]/.test(value)) {
|
||||
try { return JSON.parse(value); } catch {}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionTokens(tokens: string[]) {
|
||||
const values: Record<string, unknown> = {};
|
||||
const positionals: string[] = [];
|
||||
for (let index = 0; index < tokens.length; index++) {
|
||||
const token = tokens[index];
|
||||
if (!token.startsWith("--")) {
|
||||
positionals.push(token);
|
||||
continue;
|
||||
}
|
||||
const [rawName, inline] = token.split("=", 2);
|
||||
const name = normalizeOption(rawName);
|
||||
if (!name) throw new CliError(`Invalid option: ${token}`);
|
||||
if (inline !== undefined) values[name] = coerce(inline);
|
||||
else if (tokens[index + 1] && !tokens[index + 1].startsWith("--")) values[name] = coerce(tokens[++index]);
|
||||
else values[name] = true;
|
||||
}
|
||||
return { values, positionals };
|
||||
}
|
||||
|
||||
function readJson(location: string, io: Io) {
|
||||
const source = location === "-" ? io.readStdin() : fs.readFileSync(path.resolve(location), "utf8");
|
||||
try {
|
||||
const value = JSON.parse(source);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected a JSON object");
|
||||
return value as Record<string, unknown>;
|
||||
} catch (cause) {
|
||||
throw new CliError(`Invalid JSON input: ${cause instanceof Error ? cause.message : String(cause)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parse(argv: string[], io: Io): Parsed {
|
||||
if (!argv.length || argv.includes("--help") || argv[0] === "help") throw new CliError(helpText(), 0, "HELP");
|
||||
if (argv[0] === "--version" || argv[0] === "version") throw new CliError(CLI_VERSION, 0, "VERSION");
|
||||
if (argv.length < 2) throw new CliError("Expected a resource and action. Run `formulation help` for commands.");
|
||||
const command = `${argv[0]} ${argv[1]}`;
|
||||
const definition = commands[command];
|
||||
if (!definition) throw new CliError(`Unknown command: ${command}`);
|
||||
const parsed = optionTokens(argv.slice(2));
|
||||
const databaseValue = parsed.values.database;
|
||||
const databasePath = path.resolve(String(databaseValue ?? process.env.FORMULATION_DATABASE ?? defaultDatabasePath));
|
||||
const inputPath = parsed.values.input == null ? undefined : String(parsed.values.input);
|
||||
const outputPath = parsed.values.output == null ? undefined : String(parsed.values.output);
|
||||
const compact = parsed.values.compact === true;
|
||||
const dryRun = parsed.values.dry_run === true;
|
||||
const yes = parsed.values.yes === true;
|
||||
let values = inputPath ? readJson(inputPath, io) : {};
|
||||
const commandOptions = new Set(definition.options ?? []);
|
||||
for (const [name, value] of Object.entries(parsed.values)) {
|
||||
if (globalOptions.has(name)) continue;
|
||||
if (definition.options && !commandOptions.has(name)) throw new CliError(`Unknown option for ${command}: --${name.replaceAll("_", "-")}`);
|
||||
values[name] = value;
|
||||
}
|
||||
parsed.positionals.forEach((value, index) => {
|
||||
const name = definition.positionals?.[index];
|
||||
if (!name) throw new CliError(`Unexpected argument: ${value}`);
|
||||
values[name] = coerce(value);
|
||||
});
|
||||
for (const required of definition.required ?? []) {
|
||||
if (values[required] == null || values[required] === "") throw new CliError(`Missing required value: ${required}`);
|
||||
}
|
||||
if (dryRun && !definition.mutation) throw new CliError(`--dry-run is only valid for mutation commands.`);
|
||||
if (definition.destructive && !dryRun && !yes) throw new CliError(`${command} requires --yes (or use --dry-run).`);
|
||||
return { command, definition, values, compact, dryRun, yes, databasePath, inputPath, outputPath };
|
||||
}
|
||||
|
||||
function migrate(database: DatabaseSync) {
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
const directory = path.resolve(process.cwd(), "migrations");
|
||||
for (const file of fs.readdirSync(directory).filter((name) => name.endsWith(".sql")).sort()) {
|
||||
database.exec(fs.readFileSync(path.join(directory, file), "utf8"));
|
||||
}
|
||||
}
|
||||
|
||||
function openDatabase(databasePath: string, readOnly: boolean, create = false) {
|
||||
if (!fs.existsSync(databasePath) && !create) throw new CliError(`Database not found: ${databasePath}`, EXIT_SYSTEM, "DATABASE_NOT_FOUND");
|
||||
if (create) fs.mkdirSync(path.dirname(databasePath), { recursive: true });
|
||||
const database = new DatabaseSync(databasePath, { readOnly });
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
if (create && !database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='recipes'").get()) migrate(database);
|
||||
return database;
|
||||
}
|
||||
|
||||
function stableKey(row: Record<string, unknown>, index: number) {
|
||||
const fields = ["id", "recipe_id", "ingredient_id", "purchase_item_id", "count_id", "collection_id", "action_id", "name"];
|
||||
const values = fields.filter((field) => row[field] != null).map((field) => `${field}:${row[field]}`);
|
||||
return values.length ? values.join("|") : String(index);
|
||||
}
|
||||
|
||||
function diffBundles(current: any, incoming: any) {
|
||||
const tables: Record<string, unknown> = {};
|
||||
const names = new Set([...Object.keys(current.data ?? {}), ...Object.keys(incoming.data ?? {})]);
|
||||
let equal = true;
|
||||
for (const name of [...names].sort()) {
|
||||
const left = (current.data?.[name] ?? []) as Record<string, unknown>[];
|
||||
const right = (incoming.data?.[name] ?? []) as Record<string, unknown>[];
|
||||
const leftMap = new Map(left.map((row, index) => [stableKey(row, index), JSON.stringify(row)]));
|
||||
const rightMap = new Map(right.map((row, index) => [stableKey(row, index), JSON.stringify(row)]));
|
||||
const added = [...rightMap.keys()].filter((key) => !leftMap.has(key));
|
||||
const removed = [...leftMap.keys()].filter((key) => !rightMap.has(key));
|
||||
const changed = [...leftMap.keys()].filter((key) => rightMap.has(key) && leftMap.get(key) !== rightMap.get(key));
|
||||
if (added.length || removed.length || changed.length) equal = false;
|
||||
tables[name] = { current_count: left.length, input_count: right.length, added, removed, changed };
|
||||
}
|
||||
return { equal, current_summary: current.summary, input_summary: incoming.summary, tables };
|
||||
}
|
||||
|
||||
async function execute(parsed: Parsed, io: Io) {
|
||||
const { definition } = parsed;
|
||||
if (definition.custom === "validate") {
|
||||
if (!parsed.inputPath) throw new CliError("db validate requires --input <backup.json>.");
|
||||
const result = validateBackupBundle(readJson(parsed.inputPath, io) as any);
|
||||
if (!result.valid) throw new CliError(`Backup validation failed: ${result.errors.join("; ")}`, EXIT_DOMAIN, "VALIDATION_ERROR");
|
||||
return result;
|
||||
}
|
||||
|
||||
let activePath = parsed.databasePath;
|
||||
let temporaryDirectory: string | undefined;
|
||||
if (parsed.dryRun) {
|
||||
if (!fs.existsSync(parsed.databasePath)) throw new CliError(`Database not found: ${parsed.databasePath}`, EXIT_SYSTEM, "DATABASE_NOT_FOUND");
|
||||
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "formulation-cli-"));
|
||||
activePath = path.join(temporaryDirectory, "dry-run.sqlite");
|
||||
const source = openDatabase(parsed.databasePath, true);
|
||||
try { await backup(source, activePath); } finally { source.close(); }
|
||||
}
|
||||
|
||||
const create = definition.custom === "import" && !fs.existsSync(activePath);
|
||||
const database = openDatabase(activePath, !definition.mutation, create);
|
||||
try {
|
||||
if (definition.custom === "export") return exportDatabase(database);
|
||||
if (definition.custom === "diff") {
|
||||
if (!parsed.inputPath) throw new CliError("db diff requires --input <backup.json>.");
|
||||
const incoming = readJson(parsed.inputPath, io);
|
||||
const validation = validateBackupBundle(incoming as any);
|
||||
if (!validation.valid) throw new CliError(`Backup validation failed: ${validation.errors.join("; ")}`, EXIT_DOMAIN, "VALIDATION_ERROR");
|
||||
return diffBundles(exportDatabase(database), incoming);
|
||||
}
|
||||
if (definition.custom === "import") {
|
||||
if (!parsed.inputPath) throw new CliError("db import requires --input <backup.json>.");
|
||||
return importDatabase(database, readJson(parsed.inputPath, io) as any, { mode: parsed.values.mode === "merge" ? "merge" : "replace", rebuildProjections: !parsed.dryRun });
|
||||
}
|
||||
const tools = createDomainTools(() => database) as unknown as Record<string, (values?: Record<string, unknown>) => unknown>;
|
||||
const method = tools[definition.method!];
|
||||
if (!method) throw new CliError(`CLI command is not implemented: ${parsed.command}`, EXIT_SYSTEM, "NOT_IMPLEMENTED");
|
||||
return method(parsed.values);
|
||||
} finally {
|
||||
database.close();
|
||||
if (temporaryDirectory) {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
const source = openDatabase(parsed.databasePath, true);
|
||||
try { refreshSiteProjection(source); } finally { source.close(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeResult(parsed: Parsed, result: unknown, io: Io) {
|
||||
const envelope = { success: true, command: parsed.command, dry_run: parsed.dryRun, data: result };
|
||||
const serialized = JSON.stringify(envelope, null, parsed.compact ? 0 : 2);
|
||||
if (parsed.outputPath) {
|
||||
const target = path.resolve(parsed.outputPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
const content = parsed.definition.custom === "export" ? JSON.stringify(result, null, 2) : serialized;
|
||||
fs.writeFileSync(target, `${content}\n`, "utf8");
|
||||
io.stdout(`${JSON.stringify({ success: true, command: parsed.command, dry_run: parsed.dryRun, output: target })}\n`);
|
||||
} else io.stdout(`${serialized}\n`);
|
||||
}
|
||||
|
||||
export function helpText() {
|
||||
const rows = Object.entries(commands).map(([name, definition]) => ` ${name.padEnd(22)} ${definition.summary}`).join("\n");
|
||||
return `Formulation CLI ${CLI_VERSION}\n\nUsage:\n formulation <resource> <action> [arguments] [options]\n\nCommands:\n${rows}\n\nGlobal options:\n --database <path> SQLite database (default: var/recipe-book.sqlite)\n --input <file|-> Merge a JSON object from a file or stdin\n --output <file> Write command data to a file\n --dry-run Execute a mutation against a temporary database clone\n --yes Confirm permanent deletion or database replacement\n --compact Emit compact JSON\n --help Show this help\n --version Show the CLI version\n\nExamples:\n formulation recipe list --query chipotle --limit 5\n formulation recipe get <id> --target-yield 4000 --target-yield-unit gram\n formulation recipe save --input recipe.json --dry-run\n formulation db export --output data/recipe-book-backup.json\n formulation db diff --input data/recipe-book-backup.json\n`;
|
||||
}
|
||||
|
||||
export async function runCli(argv: string[], overrides: Partial<Io> = {}) {
|
||||
const io: Io = {
|
||||
stdout: overrides.stdout ?? ((text) => process.stdout.write(text)),
|
||||
stderr: overrides.stderr ?? ((text) => process.stderr.write(text)),
|
||||
readStdin: overrides.readStdin ?? (() => fs.readFileSync(0, "utf8")),
|
||||
};
|
||||
try {
|
||||
const parsed = parse(argv, io);
|
||||
const result = await execute(parsed, io);
|
||||
writeResult(parsed, result, io);
|
||||
return 0;
|
||||
} catch (cause) {
|
||||
if (cause instanceof CliError && cause.exitCode === 0) {
|
||||
io.stdout(`${cause.message}\n`);
|
||||
return 0;
|
||||
}
|
||||
const exitCode = cause instanceof CliError ? cause.exitCode : cause instanceof Error ? EXIT_DOMAIN : EXIT_SYSTEM;
|
||||
const code = cause instanceof CliError ? cause.code : exitCode === EXIT_DOMAIN ? "DOMAIN_ERROR" : "SYSTEM_ERROR";
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
io.stderr(`${JSON.stringify({ success: false, error: { code, message } })}\n`);
|
||||
return exitCode;
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -7,10 +7,11 @@ import type {
|
||||
Unit,
|
||||
SourceMapping,
|
||||
} from "./types.ts";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { databaseProjection, openDatabase } from "./database.ts";
|
||||
|
||||
export function loadCatalogs() {
|
||||
const database = openDatabase();
|
||||
export function loadCatalogs(existingDatabase?: DatabaseSync) {
|
||||
const database = existingDatabase ?? openDatabase();
|
||||
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 {
|
||||
@@ -35,6 +36,6 @@ export function loadCatalogs() {
|
||||
sourceMappings: map(projection.sourceMappings),
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
if (!existingDatabase) database.close();
|
||||
}
|
||||
}
|
||||
+15
-11
@@ -26,7 +26,7 @@ import { newRecipeId } from "./recipe-id.ts";
|
||||
export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
return {
|
||||
searchRecipes(args: { query?: string; category?: string; tag?: string; limit?: number }) {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
let results = [...catalogs.recipes.values()] as Recipe[];
|
||||
@@ -67,7 +67,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
getRecipe(args: { id: string; scale_factor?: number; target_yield?: number; target_yield_unit?: string }) {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const recipe = catalogs.recipes.get(args.id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.id}`);
|
||||
|
||||
@@ -152,6 +152,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
yield_quantity: number;
|
||||
yield_unit_id: string;
|
||||
serving_size?: { quantity: number; unit_id: string } | null;
|
||||
expected_save_version?: number;
|
||||
yield_basis?: string | null;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
@@ -183,6 +184,9 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
let recipeId: string;
|
||||
const existing = args.id ? (db.prepare("SELECT save_version FROM recipes WHERE id = ?").get(args.id) as any) : null;
|
||||
if (args.id && !existing) throw new Error("Unknown recipe ID. Omit id when creating a recipe.");
|
||||
if (existing && args.expected_save_version != null && existing.save_version !== args.expected_save_version) {
|
||||
throw new Error(`Recipe changed: expected save version ${args.expected_save_version}, found ${existing.save_version}.`);
|
||||
}
|
||||
const isNew = !existing;
|
||||
|
||||
if (isNew) {
|
||||
@@ -251,7 +255,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
saveRecipeStructure(db, recipeId, structure as any);
|
||||
|
||||
if(args.serving_size){
|
||||
const catalogs=loadCatalogs(),savedRecipe=catalogs.recipes.get(recipeId);
|
||||
const catalogs=loadCatalogs(db),savedRecipe=catalogs.recipes.get(recipeId);
|
||||
if(!savedRecipe)throw new Error("Saved recipe could not be reloaded.");
|
||||
const conversionSubject={schema_version:2 as const,id:recipeId,name:args.title,status:"active",categories:[],measure_conversions:savedRecipe.measure_conversions};
|
||||
try{
|
||||
@@ -335,7 +339,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
calculateRecipeCost(args: { recipe_id: string; currency?: string }): CostResult {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
|
||||
@@ -349,7 +353,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
calculateRecipeNutrition(args: { recipe_id: string; serving_size_g?: number }): NutritionResult {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const recipe = catalogs.recipes.get(args.recipe_id);
|
||||
if (!recipe) throw new Error(`Recipe not found: ${args.recipe_id}`);
|
||||
|
||||
@@ -362,7 +366,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
searchIngredients(args: { query?: string; category?: string; missing_cost?: boolean; limit?: number }) {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const q = (args.query ?? "").trim().toLowerCase();
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
|
||||
@@ -416,7 +420,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
getIngredient(args: { id: string }) {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const ing = catalogs.ingredients.get(args.id);
|
||||
if (!ing) throw new Error(`Ingredient not found: ${args.id}`);
|
||||
|
||||
@@ -752,7 +756,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
convertUnits(args: { ingredient_id?: string; quantity: number; from_unit: string; to_unit: string }) {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
const units = catalogs.units;
|
||||
|
||||
const fromUnit = units.get(args.from_unit);
|
||||
@@ -792,7 +796,7 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
listUnits(args?: { dimension?: string; system?: string }): Unit[] {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
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);
|
||||
@@ -800,14 +804,14 @@ export function createDomainTools(getDb: () => DatabaseSync) {
|
||||
},
|
||||
|
||||
listEquipment(args?: { category?: string }): Equipment[] {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
let items = [...catalogs.equipment.values()];
|
||||
if (args?.category) items = items.filter((e) => e.category === args.category);
|
||||
return items;
|
||||
},
|
||||
|
||||
listPrepActions(): PrepAction[] {
|
||||
const catalogs = loadCatalogs();
|
||||
const catalogs = loadCatalogs(getDb());
|
||||
return [...catalogs.prepActions.values()];
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user