From 01e8dac3597b40a119a557e0a1eb0ce3f8b07c35 Mon Sep 17 00:00:00 2001 From: Nicholas Ward Date: Mon, 31 Aug 2026 09:38:38 -0500 Subject: [PATCH] feat(cli): add local domain command surface --- package.json | 4 + scripts/formulation.mjs | 4 + src/cli/commands.ts | 49 ++++++++ src/cli/run.ts | 252 ++++++++++++++++++++++++++++++++++++++++ src/lib/data.ts | 7 +- src/lib/domain-tools.ts | 26 +++-- 6 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 scripts/formulation.mjs create mode 100644 src/cli/commands.ts create mode 100644 src/cli/run.ts diff --git a/package.json b/package.json index 38485da..b762716 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/formulation.mjs b/scripts/formulation.mjs new file mode 100644 index 0000000..50c79bf --- /dev/null +++ b/scripts/formulation.mjs @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { runCli } from "../src/cli/run.ts"; + +process.exitCode = await runCli(process.argv.slice(2)); diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..fa6495d --- /dev/null +++ b/src/cli/commands.ts @@ -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 = { + "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"]); diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 0000000..e1a10e5 --- /dev/null +++ b/src/cli/run.ts @@ -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; + 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 = {}; + 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; + } 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, 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 = {}; + 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[]; + const right = (incoming.data?.[name] ?? []) as Record[]; + 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 ."); + 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 ."); + 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 ."); + 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) => 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 [arguments] [options]\n\nCommands:\n${rows}\n\nGlobal options:\n --database SQLite database (default: var/recipe-book.sqlite)\n --input Merge a JSON object from a file or stdin\n --output 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 --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 = {}) { + 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; + } +} diff --git a/src/lib/data.ts b/src/lib/data.ts index b7366e1..617b430 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -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(); } } diff --git a/src/lib/domain-tools.ts b/src/lib/domain-tools.ts index 91e8531..4392c89 100644 --- a/src/lib/domain-tools.ts +++ b/src/lib/domain-tools.ts @@ -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()]; },