This commit is contained in:
Your Name committed 2026-07-25 01:41:21 -04:00
1 parent 167d07f0c2
commit 9a915ee395
6 files changed
+264 -40

No files matched your search

+2
View File
@@ -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. 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. 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 ## What to Expect
@@ -1,10 +1,14 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
buildFooterMenuAddDryRun,
canBootstrapFooterMenu,
compactMissingFooterMenu,
createMenuTranslations, createMenuTranslations,
executeVersionedMenuWrite, executeVersionedMenuWrite,
fingerprintMenu, fingerprintMenu,
isValidMenuUrl, isValidMenuUrl,
lockMenuForTransaction, lockMenuForTransaction,
lockMenuNameForTransaction,
menusRoute, menusRoute,
resolveMenuItemLabel, resolveMenuItemLabel,
} from "./menus"; } from "./menus";
@@ -138,6 +142,59 @@ describe("footer menu helpers", () => {
expect(query).toBe("SELECT pg_advisory_xact_lock(?)"); expect(query).toBe("SELECT pg_advisory_xact_lock(?)");
expect(values).toEqual([menu.id]); 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", () => { describe("footer menu route validation", () => {
+136 -38
View File
@@ -115,6 +115,10 @@ export const lockMenuForTransaction = async (tx: any, menuId: number) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`; 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<T>( export async function executeVersionedMenuWrite<T>(
menu: VersionedMenu, menu: VersionedMenu,
expectedVersion: unknown, expectedVersion: unknown,
@@ -160,6 +164,7 @@ const runMenuTransaction = async <T>(
write: (tx: any, menu: VersionedMenu) => Promise<T>, write: (tx: any, menu: VersionedMenu) => Promise<T>,
) => ) =>
prisma.$transaction(async (tx: any) => { prisma.$transaction(async (tx: any) => {
await lockMenuNameForTransaction(tx, name);
const identity = await tx.menu.findUnique({ const identity = await tx.menu.findUnique({
where: { name }, where: { name },
select: { id: true }, select: { id: true },
@@ -177,6 +182,47 @@ const runMenuTransaction = async <T>(
}); });
}); });
const runAddMenuTransaction = async (
name: FooterMenuName,
expectedVersion: unknown,
write: (tx: any, menu: VersionedMenu) => Promise<unknown>,
) =>
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 = ( const unwrapTransaction = (
result: Awaited<ReturnType<typeof runMenuTransaction>>, result: Awaited<ReturnType<typeof runMenuTransaction>>,
name: FooterMenuName, 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<string, string>;
},
) => ({
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" }) export const menusRoute = new Elysia({ prefix: "/menus" })
.get("/", async () => { .get("/", async () => {
return await prisma.menu.findMany({ return await prisma.menu.findMany({
@@ -250,9 +336,9 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
} }
const menu = await getFooterMenu(name); const menu = await getFooterMenu(name);
if (!menu) { 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 }) => { .get("/:name", async ({ params: { name }, set }) => {
const menu = await prisma.menu.findUnique({ 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); return errorResponse(set, 400, "INVALID_LOCALE", parsedTranslations.error);
} }
const menu = await getFooterMenu(name); const menu = await getFooterMenu(name);
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`); if (!menu && !canBootstrapFooterMenu(input.expectedVersion)) {
const versionCheck = checkVersion(menu, input.expectedVersion, set); return errorResponse(
if (versionCheck.conflict) return versionCheck.conflict; 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 = { const preview = {
label: input.label, label: input.label,
url: input.url, url: input.url,
order: input.order ?? menu.items.length, order: input.order ?? menu?.items.length ?? 0,
columnImage: input.columnImage ?? null, columnImage: input.columnImage ?? null,
hoverImage: input.hoverImage ?? null, hoverImage: input.hoverImage ?? null,
}; };
if (input.dryRun === true) { if (input.dryRun === true) {
return { return buildFooterMenuAddDryRun(menu, input);
success: true,
dryRun: true,
operation: "add",
version: versionCheck.currentVersion,
item: { ...preview, translations: input.translations ?? {} },
};
} }
const transaction = unwrapTransaction( const transaction = await runAddMenuTransaction(
await runMenuTransaction(name, input.expectedVersion, (tx, currentMenu) => name,
input.expectedVersion,
(tx, currentMenu) =>
tx.menuItem.create({ tx.menuItem.create({
data: { data: {
menuId: currentMenu.id, menuId: currentMenu.id,
@@ -317,16 +408,24 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
: undefined, : undefined,
}, },
include: { translations: true }, 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 { return {
success: true, success: true,
dryRun: false, dryRun: false,
item: transaction.value!.value, createdMenu: transaction.value.createdMenu,
version: transaction.value!.version, item: transaction.value.value,
version: transaction.value.version,
}; };
}, { }, {
body: t.Object({ body: t.Object({
@@ -337,7 +436,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
hoverImage: t.Optional(t.Nullable(t.String())), hoverImage: t.Optional(t.Nullable(t.String())),
translations: t.Optional(t.Record(t.String(), t.String({ minLength: 1 }))), translations: t.Optional(t.Record(t.String(), t.String({ minLength: 1 }))),
dryRun: t.Optional(t.Boolean()), 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) => { .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 }[] translations?: { langCode: string; label: string }[]
}[] }[]
}; };
let menu = await prisma.menu.findUnique({ where: { name } });
const updateData: any = {}; const updateData: any = {};
if (payload !== undefined) updateData.payload = payload; if (payload !== undefined) updateData.payload = payload;
if (isActive !== undefined) updateData.isActive = isActive; if (isActive !== undefined) updateData.isActive = isActive;
if (rootImage !== undefined) updateData.rootImage = rootImage; if (rootImage !== undefined) updateData.rootImage = rootImage;
if (!menu) { return await prisma.$transaction(async (tx: any) => {
menu = await prisma.menu.create({ data: { name, ...updateData } }); await lockMenuNameForTransaction(tx, name);
} else if (Object.keys(updateData).length > 0) { let menu = await tx.menu.findUnique({ where: { name } });
menu = await prisma.menu.update({ where: { name }, data: updateData }); 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);
await lockMenuForTransaction(tx, menu!.id); const existingItems = await tx.menuItem.findMany({ where: { menuId: menu.id } });
const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } });
const existingItemIds = existingItems.map((i: any) => i.id); const existingItemIds = existingItems.map((i: any) => i.id);
const payloadItemIds = items.map(i => i.id).filter(id => id != null); const payloadItemIds = items.map(i => i.id).filter(id => id != null);
const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id)); const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id));
@@ -675,7 +774,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
} else { } else {
await tx.menuItem.create({ await tx.menuItem.create({
data: { data: {
menuId: menu!.id, menuId: menu.id,
label: item.label, label: item.label,
url: item.url, url: item.url,
order: item.order, order: item.order,
@@ -691,11 +790,10 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
}); });
} }
} }
}); return tx.menu.findUnique({
where: { name },
return await prisma.menu.findUnique({ include: { items: { orderBy: { order: "asc" }, include: { translations: true } } }
where: { name }, });
include: { items: { orderBy: { order: "asc" }, include: { translations: true } } }
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
+2
View File
@@ -52,6 +52,8 @@ Use this workflow for every Footer menu edit:
5. Apply the same operation with `dryRun: false` and the same version. 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. 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: Available item-level tools:
- `add_footer_menu_item` - `add_footer_menu_item`
+63
View File
@@ -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 () => { test("adds an item with write controls", async () => {
const { api, calls } = createApi(); const { api, calls } = createApi();
await handleMenuTool(api, "add_footer_menu_item", { 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 () => { test("partially updates an item without forwarding unrelated fields", async () => {
const { api, calls } = createApi(); const { api, calls } = createApi();
await handleMenuTool(api, "update_footer_menu_item", { await handleMenuTool(api, "update_footer_menu_item", {
+4 -2
View File
@@ -107,7 +107,7 @@ const rootMenuNodeSchema = {
export const MENU_TOOL_DEFINITIONS = [ export const MENU_TOOL_DEFINITIONS = [
{ {
name: "get_footer_menu", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -128,7 +128,7 @@ export const MENU_TOOL_DEFINITIONS = [
}, },
{ {
name: "add_footer_menu_item", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -435,6 +435,8 @@ function compactFooterResponse(data: any, operation: string, params: any) {
for (const key of [ for (const key of [
"dryRun", "dryRun",
"exists",
"willCreateMenu",
"version", "version",
"lang", "lang",
"item", "item",