x
This commit is contained in:
1 parent
167d07f0c2
commit
9a915ee395
6 files changed
+264
-40
No files matched your search
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user