From 9a915ee3957fad355d05d4b66737ce33c5e15ae4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 25 Jul 2026 01:41:21 -0400 Subject: [PATCH] x --- LLM_MCP_GUIDE.md | 2 + apps/backend/src/routes/menus.footer.test.ts | 57 ++++++ apps/backend/src/routes/menus.ts | 174 +++++++++++++++---- apps/mcp-server/README.md | 2 + apps/mcp-server/menu-toolkit.test.ts | 63 +++++++ apps/mcp-server/menu-toolkit.ts | 6 +- 6 files changed, 264 insertions(+), 40 deletions(-) diff --git a/LLM_MCP_GUIDE.md b/LLM_MCP_GUIDE.md index a6c0a02..a97c8be 100644 --- a/LLM_MCP_GUIDE.md +++ b/LLM_MCP_GUIDE.md @@ -190,6 +190,8 @@ Use `get_page_section` with `lang=all` only when every stored language map is ge Use `update_footer_menu_item_translation` for one language at a time. Use `reorder_footer_menu_items` only with the complete list of current item IDs. If the backend returns `MENU_VERSION_CONFLICT`, discard the old version and read the menu again. +An allowed Footer menu may not exist before its first item is saved. In that case, `get_footer_menu` returns `exists=false` and `version=null`. Run `add_footer_menu_item` with `dryRun=true`; a `willCreateMenu=true` preview confirms that the actual add will create the active menu and first item atomically. + Do not use legacy `update_footer_content` for menu arrays. It remains available only for the Footer brand description and social links. Website Collections come from Categories, not a Footer menu. ## What to Expect diff --git a/apps/backend/src/routes/menus.footer.test.ts b/apps/backend/src/routes/menus.footer.test.ts index 382c305..c2af7df 100644 --- a/apps/backend/src/routes/menus.footer.test.ts +++ b/apps/backend/src/routes/menus.footer.test.ts @@ -1,10 +1,14 @@ import { describe, expect, test } from "bun:test"; import { + buildFooterMenuAddDryRun, + canBootstrapFooterMenu, + compactMissingFooterMenu, createMenuTranslations, executeVersionedMenuWrite, fingerprintMenu, isValidMenuUrl, lockMenuForTransaction, + lockMenuNameForTransaction, menusRoute, resolveMenuItemLabel, } from "./menus"; @@ -138,6 +142,59 @@ describe("footer menu helpers", () => { expect(query).toBe("SELECT pg_advisory_xact_lock(?)"); expect(values).toEqual([menu.id]); }); + + test("takes a transaction-scoped advisory lock keyed by menu name", async () => { + let query = ""; + let values: unknown[] = []; + await lockMenuNameForTransaction({ + $queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => { + query = strings.join("?"); + values = parameters; + return []; + }, + }, "footer-explore"); + + expect(query).toBe("SELECT pg_advisory_xact_lock(1213025877, hashtext(?))"); + expect(values).toEqual(["footer-explore"]); + }); + + test("returns the compact empty read contract for an absent footer menu", () => { + expect(compactMissingFooterMenu("footer-support", "zh")).toEqual({ + name: "footer-support", + lang: "zh", + exists: false, + version: null, + items: [], + }); + }); + + test("permits bootstrap only when expectedVersion is omitted or null", () => { + expect(canBootstrapFooterMenu(undefined)).toBe(true); + expect(canBootstrapFooterMenu(null)).toBe(true); + expect(canBootstrapFooterMenu("sha256:stale")).toBe(false); + }); + + test("previews first-item bootstrap without creating menu state", () => { + expect(buildFooterMenuAddDryRun(null, { + label: "Contact", + url: "mailto:hello@example.com", + translations: { th: "ติดต่อเรา" }, + })).toEqual({ + success: true, + dryRun: true, + operation: "add", + version: null, + willCreateMenu: true, + item: { + label: "Contact", + url: "mailto:hello@example.com", + order: 0, + columnImage: null, + hoverImage: null, + translations: { th: "ติดต่อเรา" }, + }, + }); + }); }); describe("footer menu route validation", () => { diff --git a/apps/backend/src/routes/menus.ts b/apps/backend/src/routes/menus.ts index 73ed9aa..e6d8a55 100644 --- a/apps/backend/src/routes/menus.ts +++ b/apps/backend/src/routes/menus.ts @@ -115,6 +115,10 @@ export const lockMenuForTransaction = async (tx: any, menuId: number) => { await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`; }; +export const lockMenuNameForTransaction = async (tx: any, name: FooterMenuName | string) => { + await tx.$queryRaw`SELECT pg_advisory_xact_lock(1213025877, hashtext(${name}))`; +}; + export async function executeVersionedMenuWrite( menu: VersionedMenu, expectedVersion: unknown, @@ -160,6 +164,7 @@ const runMenuTransaction = async ( write: (tx: any, menu: VersionedMenu) => Promise, ) => prisma.$transaction(async (tx: any) => { + await lockMenuNameForTransaction(tx, name); const identity = await tx.menu.findUnique({ where: { name }, select: { id: true }, @@ -177,6 +182,47 @@ const runMenuTransaction = async ( }); }); +const runAddMenuTransaction = async ( + name: FooterMenuName, + expectedVersion: unknown, + write: (tx: any, menu: VersionedMenu) => Promise, +) => + prisma.$transaction(async (tx: any) => { + await lockMenuNameForTransaction(tx, name); + let identity = await tx.menu.findUnique({ + where: { name }, + select: { id: true }, + }); + const createdMenu = !identity; + if (!identity) { + if (expectedVersion !== undefined && expectedVersion !== null) { + return { written: false as const, currentVersion: null }; + } + identity = await tx.menu.create({ + data: { name, isActive: true }, + select: { id: true }, + }); + } + + await lockMenuForTransaction(tx, identity.id); + const menu = await getFooterMenu(name, tx); + if (!menu) throw new Error(`Menu ${name} disappeared during bootstrap transaction.`); + return executeVersionedMenuWrite( + menu, + createdMenu ? undefined : expectedVersion, + async () => { + const value = await write(tx, menu); + const updated = await getFooterMenu(name, tx); + if (!updated) throw new Error(`Menu ${name} disappeared during add transaction.`); + return { + value, + version: fingerprintMenu(updated), + createdMenu, + }; + }, + ); + }); + const unwrapTransaction = ( result: Awaited>, name: FooterMenuName, @@ -229,6 +275,46 @@ const compactMenu = ( }), }); +export const compactMissingFooterMenu = ( + name: FooterMenuName, + lang: MenuLanguage | "all", +) => ({ + name, + lang, + exists: false as const, + version: null, + items: [], +}); + +export const canBootstrapFooterMenu = (expectedVersion: unknown) => + expectedVersion === undefined || expectedVersion === null; + +export const buildFooterMenuAddDryRun = ( + menu: VersionedMenu | null, + input: { + label: string; + url: string; + order?: number; + columnImage?: string | null; + hoverImage?: string | null; + translations?: Record; + }, +) => ({ + success: true, + dryRun: true, + operation: "add", + version: menu ? fingerprintMenu(menu) : null, + willCreateMenu: !menu, + item: { + label: input.label, + url: input.url, + order: input.order ?? menu?.items.length ?? 0, + columnImage: input.columnImage ?? null, + hoverImage: input.hoverImage ?? null, + translations: input.translations ?? {}, + }, +}); + export const menusRoute = new Elysia({ prefix: "/menus" }) .get("/", async () => { return await prisma.menu.findMany({ @@ -250,9 +336,9 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) } const menu = await getFooterMenu(name); if (!menu) { - return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`); + return compactMissingFooterMenu(name, lang); } - return compactMenu(menu, lang, query.fallback !== "false"); + return { ...compactMenu(menu, lang, query.fallback !== "false"), exists: true }; }) .get("/:name", async ({ params: { name }, set }) => { const menu = await prisma.menu.findUnique({ @@ -283,28 +369,33 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) return errorResponse(set, 400, "INVALID_LOCALE", parsedTranslations.error); } const menu = await getFooterMenu(name); - if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`); - const versionCheck = checkVersion(menu, input.expectedVersion, set); - if (versionCheck.conflict) return versionCheck.conflict; + if (!menu && !canBootstrapFooterMenu(input.expectedVersion)) { + return errorResponse( + set, + 409, + "MENU_VERSION_CONFLICT", + "Menu does not exist, so the supplied version is stale.", + ); + } + const versionCheck = menu + ? checkVersion(menu, input.expectedVersion, set) + : { currentVersion: null }; + if ("conflict" in versionCheck && versionCheck.conflict) return versionCheck.conflict; const preview = { label: input.label, url: input.url, - order: input.order ?? menu.items.length, + order: input.order ?? menu?.items.length ?? 0, columnImage: input.columnImage ?? null, hoverImage: input.hoverImage ?? null, }; if (input.dryRun === true) { - return { - success: true, - dryRun: true, - operation: "add", - version: versionCheck.currentVersion, - item: { ...preview, translations: input.translations ?? {} }, - }; + return buildFooterMenuAddDryRun(menu, input); } - const transaction = unwrapTransaction( - await runMenuTransaction(name, input.expectedVersion, (tx, currentMenu) => + const transaction = await runAddMenuTransaction( + name, + input.expectedVersion, + (tx, currentMenu) => tx.menuItem.create({ data: { menuId: currentMenu.id, @@ -317,16 +408,24 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) : undefined, }, include: { translations: true }, - })), - name, - set, + }), ); - if (transaction.error) return transaction.error; + if (!transaction.written) { + return errorResponse( + set, + 409, + "MENU_VERSION_CONFLICT", + transaction.currentVersion === null + ? "Menu does not exist, so the supplied version is stale." + : "Menu changed after it was read. Fetch the menu again before writing.", + ); + } return { success: true, dryRun: false, - item: transaction.value!.value, - version: transaction.value!.version, + createdMenu: transaction.value.createdMenu, + item: transaction.value.value, + version: transaction.value.version, }; }, { body: t.Object({ @@ -337,7 +436,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) hoverImage: t.Optional(t.Nullable(t.String())), translations: t.Optional(t.Record(t.String(), t.String({ minLength: 1 }))), dryRun: t.Optional(t.Boolean()), - expectedVersion: t.Optional(t.String()), + expectedVersion: t.Optional(t.Nullable(t.String())), }), }) .put("/footer/:name/items/reorder", async ({ params: { name }, body, set }: any) => { @@ -627,22 +726,22 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) translations?: { langCode: string; label: string }[] }[] }; - let menu = await prisma.menu.findUnique({ where: { name } }); - const updateData: any = {}; if (payload !== undefined) updateData.payload = payload; if (isActive !== undefined) updateData.isActive = isActive; if (rootImage !== undefined) updateData.rootImage = rootImage; - if (!menu) { - menu = await prisma.menu.create({ data: { name, ...updateData } }); - } else if (Object.keys(updateData).length > 0) { - menu = await prisma.menu.update({ where: { name }, data: updateData }); - } + return await prisma.$transaction(async (tx: any) => { + await lockMenuNameForTransaction(tx, name); + let menu = await tx.menu.findUnique({ where: { name } }); + if (!menu) { + menu = await tx.menu.create({ data: { name, ...updateData } }); + } else if (Object.keys(updateData).length > 0) { + menu = await tx.menu.update({ where: { name }, data: updateData }); + } - await prisma.$transaction(async (tx: any) => { - await lockMenuForTransaction(tx, menu!.id); - const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } }); + await lockMenuForTransaction(tx, menu.id); + const existingItems = await tx.menuItem.findMany({ where: { menuId: menu.id } }); const existingItemIds = existingItems.map((i: any) => i.id); const payloadItemIds = items.map(i => i.id).filter(id => id != null); const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id)); @@ -675,7 +774,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) } else { await tx.menuItem.create({ data: { - menuId: menu!.id, + menuId: menu.id, label: item.label, url: item.url, order: item.order, @@ -691,11 +790,10 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) }); } } - }); - - return await prisma.menu.findUnique({ - where: { name }, - include: { items: { orderBy: { order: "asc" }, include: { translations: true } } } + return tx.menu.findUnique({ + where: { name }, + include: { items: { orderBy: { order: "asc" }, include: { translations: true } } } + }); }); } catch (err) { console.error(err); diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 3cb81c6..823a4e0 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -52,6 +52,8 @@ Use this workflow for every Footer menu edit: 5. Apply the same operation with `dryRun: false` and the same version. 6. Call `get_footer_menu` again to verify and obtain the next version. +If an allowed Footer menu does not exist yet, `get_footer_menu` returns `exists: false`, `version: null`, and an empty item list. Previewing the first add returns `willCreateMenu: true`; applying that add creates the menu as active and inserts the first item atomically. + Available item-level tools: - `add_footer_menu_item` diff --git a/apps/mcp-server/menu-toolkit.test.ts b/apps/mcp-server/menu-toolkit.test.ts index 811cac7..dcfd42d 100644 --- a/apps/mcp-server/menu-toolkit.test.ts +++ b/apps/mcp-server/menu-toolkit.test.ts @@ -129,6 +129,30 @@ describe("Footer MCP proxy handlers", () => { }); }); + test("preserves the compact contract for an allowed menu that does not exist", async () => { + const { api } = createApi({ + success: true, + menuName: "footer-support", + exists: false, + version: null, + lang: "th", + items: [], + }); + const result = await handleMenuTool(api, "get_footer_menu", { + menuName: "footer-support", + }); + + expect(output(result)).toEqual({ + success: true, + operation: "get_footer_menu", + menuName: "footer-support", + exists: false, + version: null, + lang: "th", + items: [], + }); + }); + test("adds an item with write controls", async () => { const { api, calls } = createApi(); await handleMenuTool(api, "add_footer_menu_item", { @@ -155,6 +179,45 @@ describe("Footer MCP proxy handlers", () => { }); }); + test("preserves a dry-run bootstrap preview for an absent allowed menu", async () => { + const { api } = createApi({ + success: true, + operation: "add_footer_menu_item", + menuName: "footer-explore", + dryRun: true, + exists: false, + willCreateMenu: true, + version: null, + item: { + label: "Shop", + url: "/shop", + order: 1, + }, + }); + const result = await handleMenuTool(api, "add_footer_menu_item", { + menuName: "footer-explore", + label: "Shop", + url: "/shop", + order: 1, + dryRun: true, + }); + + expect(output(result)).toEqual({ + success: true, + operation: "add_footer_menu_item", + menuName: "footer-explore", + dryRun: true, + exists: false, + willCreateMenu: true, + version: null, + item: { + label: "Shop", + url: "/shop", + order: 1, + }, + }); + }); + test("partially updates an item without forwarding unrelated fields", async () => { const { api, calls } = createApi(); await handleMenuTool(api, "update_footer_menu_item", { diff --git a/apps/mcp-server/menu-toolkit.ts b/apps/mcp-server/menu-toolkit.ts index 432a8a2..f64054b 100644 --- a/apps/mcp-server/menu-toolkit.ts +++ b/apps/mcp-server/menu-toolkit.ts @@ -107,7 +107,7 @@ const rootMenuNodeSchema = { export const MENU_TOOL_DEFINITIONS = [ { name: "get_footer_menu", - description: "Read footer-explore or footer-support in a compact, language-specific format. Use lang=all only when every translation is required.", + description: "Read footer-explore or footer-support in a compact, language-specific format. An allowed menu that does not exist is returned as exists:false, version:null, items:[]. Use lang=all only when every translation is required.", inputSchema: { type: "object", properties: { @@ -128,7 +128,7 @@ export const MENU_TOOL_DEFINITIONS = [ }, { name: "add_footer_menu_item", - description: "Add one item to footer-explore or footer-support without replacing the menu. Use dryRun first and pass the version from get_footer_menu.", + description: "Add one item to footer-explore or footer-support without replacing the menu. The first actual add creates an allowed footer menu when it does not exist. Use dryRun first and pass the version from get_footer_menu.", inputSchema: { type: "object", properties: { @@ -435,6 +435,8 @@ function compactFooterResponse(data: any, operation: string, params: any) { for (const key of [ "dryRun", + "exists", + "willCreateMenu", "version", "lang", "item",