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,
|
isValidMenuUrl,
|
||||||
lockMenuForTransaction,
|
lockMenuForTransaction,
|
||||||
lockMenuNameForTransaction,
|
lockMenuNameForTransaction,
|
||||||
|
MenuBusyError,
|
||||||
menusRoute,
|
menusRoute,
|
||||||
|
prepareMenuForAdminWrite,
|
||||||
resolveMenuItemLabel,
|
resolveMenuItemLabel,
|
||||||
|
runAddMenuTransaction,
|
||||||
} from "./menus";
|
} from "./menus";
|
||||||
|
|
||||||
const menu = {
|
const menu = {
|
||||||
@@ -135,11 +138,11 @@ describe("footer menu helpers", () => {
|
|||||||
$queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => {
|
$queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => {
|
||||||
query = strings.join("?");
|
query = strings.join("?");
|
||||||
values = parameters;
|
values = parameters;
|
||||||
return [];
|
return [{ locked: true }];
|
||||||
},
|
},
|
||||||
}, menu.id);
|
}, 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]);
|
expect(values).toEqual([menu.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,14 +153,113 @@ describe("footer menu helpers", () => {
|
|||||||
$queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => {
|
$queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => {
|
||||||
query = strings.join("?");
|
query = strings.join("?");
|
||||||
values = parameters;
|
values = parameters;
|
||||||
return [];
|
return [{ locked: true }];
|
||||||
},
|
},
|
||||||
}, "footer-explore");
|
}, "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"]);
|
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", () => {
|
test("returns the compact empty read contract for an absent footer menu", () => {
|
||||||
expect(compactMissingFooterMenu("footer-support", "zh")).toEqual({
|
expect(compactMissingFooterMenu("footer-support", "zh")).toEqual({
|
||||||
name: "footer-support",
|
name: "footer-support",
|
||||||
|
|||||||
@@ -111,12 +111,62 @@ const getFooterMenu = async (
|
|||||||
include: footerMenuInclude,
|
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) => {
|
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) => {
|
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>(
|
export async function executeVersionedMenuWrite<T>(
|
||||||
@@ -162,72 +212,95 @@ const runMenuTransaction = async <T>(
|
|||||||
name: FooterMenuName,
|
name: FooterMenuName,
|
||||||
expectedVersion: unknown,
|
expectedVersion: unknown,
|
||||||
write: (tx: any, menu: VersionedMenu) => Promise<T>,
|
write: (tx: any, menu: VersionedMenu) => Promise<T>,
|
||||||
) =>
|
) => {
|
||||||
prisma.$transaction(async (tx: any) => {
|
try {
|
||||||
await lockMenuNameForTransaction(tx, name);
|
return await prisma.$transaction(async (tx: any) => {
|
||||||
const identity = await tx.menu.findUnique({
|
await lockMenuNameForTransaction(tx, name);
|
||||||
where: { name },
|
const identity = await tx.menu.findUnique({
|
||||||
select: { id: true },
|
where: { name },
|
||||||
});
|
select: { id: true },
|
||||||
if (!identity) return { missing: true as const };
|
});
|
||||||
await lockMenuForTransaction(tx, identity.id);
|
if (!identity) return { missing: true as const };
|
||||||
|
await lockMenuForTransaction(tx, identity.id);
|
||||||
|
|
||||||
const menu = await getFooterMenu(name, tx);
|
const menu = await getFooterMenu(name, tx);
|
||||||
if (!menu) return { missing: true as const };
|
if (!menu) return { missing: true as const };
|
||||||
return executeVersionedMenuWrite(menu, expectedVersion, async () => {
|
return executeVersionedMenuWrite(menu, expectedVersion, async () => {
|
||||||
const value = await write(tx, menu);
|
const value = await write(tx, menu);
|
||||||
const updated = await getFooterMenu(name, tx);
|
const updated = await getFooterMenu(name, tx);
|
||||||
if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`);
|
if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`);
|
||||||
return { value, version: fingerprintMenu(updated) };
|
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,
|
name: FooterMenuName,
|
||||||
expectedVersion: unknown,
|
expectedVersion: unknown,
|
||||||
write: (tx: any, menu: VersionedMenu) => Promise<unknown>,
|
write: (tx: any, menu: VersionedMenu) => Promise<unknown>,
|
||||||
) =>
|
db: any = prisma,
|
||||||
prisma.$transaction(async (tx: any) => {
|
) => {
|
||||||
await lockMenuNameForTransaction(tx, name);
|
try {
|
||||||
let identity = await tx.menu.findUnique({
|
return await db.$transaction(async (tx: any) => {
|
||||||
where: { name },
|
await lockMenuNameForTransaction(tx, name);
|
||||||
select: { id: true },
|
let identity = await tx.menu.findUnique({
|
||||||
});
|
where: { name },
|
||||||
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 },
|
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);
|
await lockMenuForTransaction(tx, identity.id);
|
||||||
const menu = await getFooterMenu(name, tx);
|
const menu = await getFooterMenu(name, tx);
|
||||||
if (!menu) throw new Error(`Menu ${name} disappeared during bootstrap transaction.`);
|
if (!menu) throw new Error(`Menu ${name} disappeared during bootstrap transaction.`);
|
||||||
return executeVersionedMenuWrite(
|
return executeVersionedMenuWrite(
|
||||||
menu,
|
menu,
|
||||||
createdMenu ? undefined : expectedVersion,
|
createdMenu ? undefined : expectedVersion,
|
||||||
async () => {
|
async () => {
|
||||||
const value = await write(tx, menu);
|
const value = await write(tx, menu);
|
||||||
const updated = await getFooterMenu(name, tx);
|
const updated = await getFooterMenu(name, tx);
|
||||||
if (!updated) throw new Error(`Menu ${name} disappeared during add transaction.`);
|
if (!updated) throw new Error(`Menu ${name} disappeared during add transaction.`);
|
||||||
return {
|
return {
|
||||||
value,
|
value,
|
||||||
version: fingerprintMenu(updated),
|
version: fingerprintMenu(updated),
|
||||||
createdMenu,
|
createdMenu,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MenuBusyError) return { busy: true as const };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const unwrapTransaction = (
|
const unwrapTransaction = (
|
||||||
result: Awaited<ReturnType<typeof runMenuTransaction>>,
|
result: Awaited<ReturnType<typeof runMenuTransaction>>,
|
||||||
name: FooterMenuName,
|
name: FooterMenuName,
|
||||||
set: { status?: number | string },
|
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) {
|
if ("missing" in result) {
|
||||||
return { error: errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`) };
|
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 },
|
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) {
|
if (!transaction.written) {
|
||||||
return errorResponse(
|
return errorResponse(
|
||||||
set,
|
set,
|
||||||
@@ -732,15 +813,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
|||||||
if (rootImage !== undefined) updateData.rootImage = rootImage;
|
if (rootImage !== undefined) updateData.rootImage = rootImage;
|
||||||
|
|
||||||
return await prisma.$transaction(async (tx: any) => {
|
return await prisma.$transaction(async (tx: any) => {
|
||||||
await lockMenuNameForTransaction(tx, name);
|
const menu = await prepareMenuForAdminWrite(tx, name, updateData);
|
||||||
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 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);
|
||||||
@@ -796,6 +869,14 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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);
|
console.error(err);
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Failed to update menu" };
|
return { error: "Failed to update menu" };
|
||||||
|
|||||||
Reference in new issue
Block a user