fix(housekeeping): lock poll questions by primary key

This commit is contained in:
Simo committed 2026-09-02 18:54:58 +02:00
1 parent 6e16b948e6
commit e9680775f9
2 files changed
+70 -23

No files matched your search

@@ -15,9 +15,15 @@ const sqlMocks = vi.hoisted(() => ({
})); }));
const database = vi.hoisted(() => { const database = vi.hoisted(() => {
type SelectedResult =
| Record<string, unknown>
| readonly Record<string, unknown>[];
let selected: Record<string, unknown> = { id: 7, title: "Existing" }; let selected: Record<string, unknown> = { id: 7, title: "Existing" };
let selectedQueue: Record<string, unknown>[] = []; let selectedQueue: SelectedResult[] = [];
let selectedTable: string | undefined; let selectedTable: string | undefined;
let selectedCondition:
| { operator: string; column: unknown; value: unknown }
| undefined;
let lockedTables: Array<readonly [string | undefined, string]> = []; let lockedTables: Array<readonly [string | undefined, string]> = [];
let mutationSteps: string[] = []; let mutationSteps: string[] = [];
const insertedValues = vi.fn(async (_values: Record<string, unknown>) => [ const insertedValues = vi.fn(async (_values: Record<string, unknown>) => [
@@ -29,16 +35,33 @@ const database = vi.hoisted(() => {
values, values,
})); }));
const update = vi.fn(() => ({ set })); const update = vi.fn(() => ({ set }));
const nextSelected = () => [selectedQueue.shift() ?? selected]; const nextSelected = () => {
const result = selectedQueue.shift() ?? selected;
return Array.isArray(result) ? [...result] : [result];
};
const limit = vi.fn(async () => nextSelected()); const limit = vi.fn(async () => nextSelected());
const lock = vi.fn(async (strength: string) => { const lock = vi.fn(async (strength: string) => {
lockedTables.push([selectedTable, strength]); lockedTables.push([selectedTable, strength]);
mutationSteps.push(`lock:${selectedTable ?? "unknown"}`); mutationSteps.push(
`lock:${selectedTable ?? "unknown"}:${selectedCondition?.operator ?? "none"}:${String(selectedCondition?.column ?? "none")}:${String(selectedCondition?.value ?? "none")}`,
);
return nextSelected(); return nextSelected();
}); });
const where = vi.fn(() => ({ for: lock, limit })); const orderBy = vi.fn(async (column: unknown) => {
mutationSteps.push(
`read:${selectedTable ?? "unknown"}:${selectedCondition?.operator ?? "none"}:${String(selectedCondition?.column ?? "none")}:orderBy:${String(column)}`,
);
return nextSelected();
});
const where = vi.fn(
(condition: { operator: string; column: unknown; value: unknown }) => {
selectedCondition = condition;
return { for: lock, limit, orderBy };
},
);
const from = vi.fn((table: unknown) => { const from = vi.fn((table: unknown) => {
selectedTable = (table as { tableName?: string }).tableName; selectedTable = (table as { tableName?: string }).tableName;
selectedCondition = undefined;
return { where }; return { where };
}); });
const select = vi.fn(() => ({ from })); const select = vi.fn(() => ({ from }));
@@ -75,10 +98,11 @@ const database = vi.hoisted(() => {
selected = value; selected = value;
selectedQueue = []; selectedQueue = [];
selectedTable = undefined; selectedTable = undefined;
selectedCondition = undefined;
lockedTables = []; lockedTables = [];
mutationSteps = []; mutationSteps = [];
}, },
queueSelected(...values: Record<string, unknown>[]) { queueSelected(...values: SelectedResult[]) {
selectedQueue = [...values]; selectedQueue = [...values];
}, },
}; };
@@ -86,8 +110,16 @@ const database = vi.hoisted(() => {
vi.mock("drizzle-orm", () => ({ vi.mock("drizzle-orm", () => ({
count: vi.fn(() => "count"), count: vi.fn(() => "count"),
eq: vi.fn(), eq: vi.fn((column: unknown, value: unknown) => ({
inArray: vi.fn(), operator: "eq",
column,
value,
})),
inArray: vi.fn((column: unknown, value: unknown) => ({
operator: "inArray",
column,
value,
})),
sql: Object.assign(sqlMocks.tagged, { sql: Object.assign(sqlMocks.tagged, {
join: sqlMocks.join, join: sqlMocks.join,
raw: sqlMocks.raw, raw: sqlMocks.raw,
@@ -173,17 +205,22 @@ describe("Content database mutation runtime partial updates", () => {
]); ]);
}); });
it("deletes poll votes, questions, and the poll in child-first order", async () => { it("locks poll questions by ascending primary key before child-first deletion", async () => {
database.selected({ database.queueSelected(
id: 7, {
title: "Existing", id: 7,
description: null, title: "Existing",
status: "draft", description: null,
showResults: 1, status: "draft",
multipleChoice: 0, showResults: 1,
startsAt: null, multipleChoice: 0,
endsAt: null, startsAt: null,
}); endsAt: null,
},
[{ id: 4 }, { id: 9 }],
{ id: 4 },
{ id: 9 },
);
await executeContentDatabaseMutation( await executeContentDatabaseMutation(
"poll.change", "poll.change",
@@ -200,10 +237,13 @@ describe("Content database mutation runtime partial updates", () => {
expect(database.lockedTables()).toEqual([ expect(database.lockedTables()).toEqual([
["WebsitePoll", "update"], ["WebsitePoll", "update"],
["WebsitePollQuestion", "update"], ["WebsitePollQuestion", "update"],
["WebsitePollQuestion", "update"],
]); ]);
expect(database.mutationSteps()).toEqual([ expect(database.mutationSteps()).toEqual([
"lock:WebsitePoll", "lock:WebsitePoll:eq:id:7",
"lock:WebsitePollQuestion", "read:WebsitePollQuestion:eq:pollId:orderBy:id",
"lock:WebsitePollQuestion:eq:id:4",
"lock:WebsitePollQuestion:eq:id:9",
"delete:WebsitePollVote", "delete:WebsitePollVote",
"delete:WebsitePollQuestion", "delete:WebsitePollQuestion",
"delete:WebsitePoll", "delete:WebsitePoll",
@@ -241,7 +281,7 @@ describe("Content database mutation runtime partial updates", () => {
["WebsitePollQuestion", "update"], ["WebsitePollQuestion", "update"],
]); ]);
expect(database.mutationSteps()).toEqual([ expect(database.mutationSteps()).toEqual([
"lock:WebsitePollQuestion", "lock:WebsitePollQuestion:eq:id:7",
"delete:WebsitePollVote", "delete:WebsitePollVote",
"delete:WebsitePollQuestion", "delete:WebsitePollQuestion",
]); ]);
@@ -635,11 +635,18 @@ async function pollChange(
.for("update"); .for("update");
if (!existing) throw notFound(); if (!existing) throw notFound();
if (action === "delete") { if (action === "delete") {
await connection const questionIds = await connection
.select({ id: WebsitePollQuestion.id }) .select({ id: WebsitePollQuestion.id })
.from(WebsitePollQuestion) .from(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.pollId, id)) .where(eq(WebsitePollQuestion.pollId, id))
.for("update"); .orderBy(WebsitePollQuestion.id);
for (const question of questionIds) {
await connection
.select({ id: WebsitePollQuestion.id })
.from(WebsitePollQuestion)
.where(eq(WebsitePollQuestion.id, question.id))
.for("update");
}
await connection await connection
.delete(WebsitePollVote) .delete(WebsitePollVote)
.where( .where(