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.
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
@@ -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", () => {
+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})`;
};
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>(
menu: VersionedMenu,
expectedVersion: unknown,
@@ -160,6 +164,7 @@ const runMenuTransaction = async <T>(
write: (tx: any, menu: VersionedMenu) => Promise<T>,
) =>
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 <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 = (
result: Awaited<ReturnType<typeof runMenuTransaction>>,
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" })
.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);
+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.
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`
+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 () => {
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", {
+4 -2
View File
@@ -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",