x
This commit is contained in:
1 parent
9a915ee395
commit
f64fee1347
2 files changed
+249
-66
No files matched your search
@@ -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<unknown>) => {
|
||||
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",
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
) => {
|
||||
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<T>(
|
||||
@@ -162,72 +212,95 @@ const runMenuTransaction = async <T>(
|
||||
name: FooterMenuName,
|
||||
expectedVersion: unknown,
|
||||
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 },
|
||||
});
|
||||
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<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 },
|
||||
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<ReturnType<typeof runMenuTransaction>>,
|
||||
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" };
|
||||
|
||||
Reference in new issue
Block a user