diff --git a/apps/backend/src/routes/menus.footer.test.ts b/apps/backend/src/routes/menus.footer.test.ts index c2af7df..5b81961 100644 --- a/apps/backend/src/routes/menus.footer.test.ts +++ b/apps/backend/src/routes/menus.footer.test.ts @@ -9,8 +9,11 @@ import { isValidMenuUrl, lockMenuForTransaction, lockMenuNameForTransaction, + MenuBusyError, menusRoute, + prepareMenuForAdminWrite, resolveMenuItemLabel, + runAddMenuTransaction, } from "./menus"; const menu = { @@ -135,11 +138,11 @@ describe("footer menu helpers", () => { $queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => { query = strings.join("?"); values = parameters; - return []; + return [{ locked: true }]; }, }, menu.id); - expect(query).toBe("SELECT pg_advisory_xact_lock(?)"); + expect(query).toBe("SELECT pg_try_advisory_xact_lock(?) AS locked"); expect(values).toEqual([menu.id]); }); @@ -150,14 +153,113 @@ describe("footer menu helpers", () => { $queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => { query = strings.join("?"); values = parameters; - return []; + return [{ locked: true }]; }, }, "footer-explore"); - expect(query).toBe("SELECT pg_advisory_xact_lock(1213025877, hashtext(?))"); + expect(query).toBe("SELECT pg_try_advisory_xact_lock(1213025877, hashtext(?)) AS locked"); expect(values).toEqual(["footer-explore"]); }); + test("rejects a false name lock without invoking subsequent work", async () => { + let writes = 0; + const operation = async () => { + await lockMenuNameForTransaction({ + $queryRaw: async () => [{ locked: false }], + }, "footer-explore"); + writes += 1; + }; + + await expect(operation()).rejects.toBeInstanceOf(MenuBusyError); + expect(writes).toBe(0); + }); + + test("rejects a false ID lock so an enclosing bootstrap transaction can rollback", async () => { + let writes = 0; + const operation = async () => { + await lockMenuForTransaction({ + $queryRaw: async () => [{ locked: false }], + }, menu.id); + writes += 1; + }; + + await expect(operation()).rejects.toBeInstanceOf(MenuBusyError); + expect(writes).toBe(0); + }); + + test("rolls back a bootstrapped menu when the ID lock is busy", async () => { + let persistedMenu = false; + let writes = 0; + let lockCalls = 0; + const db = { + $transaction: async (callback: (tx: any) => Promise) => { + let transactionCreatedMenu = false; + const tx = { + $queryRaw: async () => { + lockCalls += 1; + return [{ locked: lockCalls === 1 }]; + }, + menu: { + findUnique: async () => null, + create: async () => { + transactionCreatedMenu = true; + return { id: 99 }; + }, + }, + }; + try { + const result = await callback(tx); + persistedMenu = transactionCreatedMenu; + return result; + } catch (error) { + persistedMenu = false; + throw error; + } + }, + }; + + const result = await runAddMenuTransaction( + "footer-explore", + null, + async () => { + writes += 1; + }, + db, + ); + + expect(result).toEqual({ busy: true }); + expect(lockCalls).toBe(2); + expect(persistedMenu).toBe(false); + expect(writes).toBe(0); + }); + + test("does not update existing Admin menu metadata when the ID lock is busy", async () => { + let lockCalls = 0; + let updates = 0; + const tx = { + $queryRaw: async () => { + lockCalls += 1; + return [{ locked: lockCalls === 1 }]; + }, + menu: { + findUnique: async () => ({ id: menu.id }), + create: async () => { + throw new Error("Existing menu must not be created."); + }, + update: async () => { + updates += 1; + return { id: menu.id }; + }, + }, + }; + + await expect( + prepareMenuForAdminWrite(tx, menu.name, { isActive: false }), + ).rejects.toBeInstanceOf(MenuBusyError); + expect(lockCalls).toBe(2); + expect(updates).toBe(0); + }); + test("returns the compact empty read contract for an absent footer menu", () => { expect(compactMissingFooterMenu("footer-support", "zh")).toEqual({ name: "footer-support", diff --git a/apps/backend/src/routes/menus.ts b/apps/backend/src/routes/menus.ts index e6d8a55..3557107 100644 --- a/apps/backend/src/routes/menus.ts +++ b/apps/backend/src/routes/menus.ts @@ -111,12 +111,62 @@ const getFooterMenu = async ( include: footerMenuInclude, }); +export class MenuBusyError extends Error { + constructor() { + super("Menu is currently being edited."); + this.name = "MenuBusyError"; + } +} + +const assertAdvisoryLock = (rows: unknown) => { + if ( + !Array.isArray(rows) + || rows.length === 0 + || (rows[0] as { locked?: unknown }).locked !== true + ) { + throw new MenuBusyError(); + } +}; + export const lockMenuForTransaction = async (tx: any, menuId: number) => { - await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`; + const rows = await tx.$queryRaw`SELECT pg_try_advisory_xact_lock(${menuId}) AS locked`; + assertAdvisoryLock(rows); + return true; }; export const lockMenuNameForTransaction = async (tx: any, name: FooterMenuName | string) => { - await tx.$queryRaw`SELECT pg_advisory_xact_lock(1213025877, hashtext(${name}))`; + const rows = await tx.$queryRaw`SELECT pg_try_advisory_xact_lock(1213025877, hashtext(${name})) AS locked`; + assertAdvisoryLock(rows); + return true; +}; + +export const prepareMenuForAdminWrite = async ( + tx: any, + name: string, + updateData: Record, +) => { + await lockMenuNameForTransaction(tx, name); + let identity = await tx.menu.findUnique({ + where: { name }, + select: { id: true }, + }); + if (!identity) { + identity = await tx.menu.create({ + data: { name }, + select: { id: true }, + }); + } + + await lockMenuForTransaction(tx, identity.id); + let menu = await tx.menu.findUnique({ where: { name } }); + if (!menu) throw new Error(`Menu ${name} disappeared before Admin write.`); + if (Object.keys(updateData).length > 0) { + menu = await tx.menu.update({ + where: { name }, + data: updateData, + }); + } + return menu; }; export async function executeVersionedMenuWrite( @@ -162,72 +212,95 @@ const runMenuTransaction = async ( name: FooterMenuName, expectedVersion: unknown, 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 }, - }); - if (!identity) return { missing: true as const }; - await lockMenuForTransaction(tx, identity.id); +) => { + try { + return await prisma.$transaction(async (tx: any) => { + await lockMenuNameForTransaction(tx, name); + const identity = await tx.menu.findUnique({ + where: { name }, + select: { id: true }, + }); + if (!identity) return { missing: true as const }; + await lockMenuForTransaction(tx, identity.id); - const menu = await getFooterMenu(name, tx); - if (!menu) return { missing: true as const }; - return executeVersionedMenuWrite(menu, expectedVersion, async () => { - const value = await write(tx, menu); - const updated = await getFooterMenu(name, tx); - if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`); - return { value, version: fingerprintMenu(updated) }; + const menu = await getFooterMenu(name, tx); + if (!menu) return { missing: true as const }; + return executeVersionedMenuWrite(menu, expectedVersion, async () => { + const value = await write(tx, menu); + const updated = await getFooterMenu(name, tx); + if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`); + return { value, version: fingerprintMenu(updated) }; + }); }); - }); + } catch (error) { + if (error instanceof MenuBusyError) return { busy: true as const }; + throw error; + } +}; -const runAddMenuTransaction = async ( +export 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 }, + db: any = prisma, +) => { + try { + return await db.$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, - }; - }, - ); - }); + 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, + }; + }, + ); + }); + } catch (error) { + if (error instanceof MenuBusyError) return { busy: true as const }; + throw error; + } +}; const unwrapTransaction = ( result: Awaited>, name: FooterMenuName, set: { status?: number | string }, ) => { + if ("busy" in result) { + return { + error: errorResponse( + set, + 409, + "MENU_BUSY", + "Menu is currently being edited. Read it again and retry.", + ), + }; + } if ("missing" in result) { return { error: errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`) }; } @@ -410,6 +483,14 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) include: { translations: true }, }), ); + if ("busy" in transaction) { + return errorResponse( + set, + 409, + "MENU_BUSY", + "Menu is currently being edited. Read it again and retry.", + ); + } if (!transaction.written) { return errorResponse( set, @@ -732,15 +813,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) if (rootImage !== undefined) updateData.rootImage = rootImage; 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 lockMenuForTransaction(tx, menu.id); + const menu = await prepareMenuForAdminWrite(tx, name, updateData); 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); @@ -796,6 +869,14 @@ export const menusRoute = new Elysia({ prefix: "/menus" }) }); }); } catch (err) { + if (err instanceof MenuBusyError) { + return errorResponse( + set, + 409, + "MENU_BUSY", + "Menu is currently being edited. Read it again and retry.", + ); + } console.error(err); set.status = 500; return { error: "Failed to update menu" };