diff --git a/src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts b/src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts index de00757f..83b9ce8f 100644 --- a/src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts +++ b/src/features/housekeeping/domains/content/services/mutation-runtime-database.test.ts @@ -15,9 +15,15 @@ const sqlMocks = vi.hoisted(() => ({ })); const database = vi.hoisted(() => { + type SelectedResult = + | Record + | readonly Record[]; let selected: Record = { id: 7, title: "Existing" }; - let selectedQueue: Record[] = []; + let selectedQueue: SelectedResult[] = []; let selectedTable: string | undefined; + let selectedCondition: + | { operator: string; column: unknown; value: unknown } + | undefined; let lockedTables: Array = []; let mutationSteps: string[] = []; const insertedValues = vi.fn(async (_values: Record) => [ @@ -29,16 +35,33 @@ const database = vi.hoisted(() => { values, })); 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 lock = vi.fn(async (strength: string) => { 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(); }); - 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) => { selectedTable = (table as { tableName?: string }).tableName; + selectedCondition = undefined; return { where }; }); const select = vi.fn(() => ({ from })); @@ -75,10 +98,11 @@ const database = vi.hoisted(() => { selected = value; selectedQueue = []; selectedTable = undefined; + selectedCondition = undefined; lockedTables = []; mutationSteps = []; }, - queueSelected(...values: Record[]) { + queueSelected(...values: SelectedResult[]) { selectedQueue = [...values]; }, }; @@ -86,8 +110,16 @@ const database = vi.hoisted(() => { vi.mock("drizzle-orm", () => ({ count: vi.fn(() => "count"), - eq: vi.fn(), - inArray: vi.fn(), + eq: vi.fn((column: unknown, value: unknown) => ({ + operator: "eq", + column, + value, + })), + inArray: vi.fn((column: unknown, value: unknown) => ({ + operator: "inArray", + column, + value, + })), sql: Object.assign(sqlMocks.tagged, { join: sqlMocks.join, 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 () => { - database.selected({ - id: 7, - title: "Existing", - description: null, - status: "draft", - showResults: 1, - multipleChoice: 0, - startsAt: null, - endsAt: null, - }); + it("locks poll questions by ascending primary key before child-first deletion", async () => { + database.queueSelected( + { + id: 7, + title: "Existing", + description: null, + status: "draft", + showResults: 1, + multipleChoice: 0, + startsAt: null, + endsAt: null, + }, + [{ id: 4 }, { id: 9 }], + { id: 4 }, + { id: 9 }, + ); await executeContentDatabaseMutation( "poll.change", @@ -200,10 +237,13 @@ describe("Content database mutation runtime partial updates", () => { expect(database.lockedTables()).toEqual([ ["WebsitePoll", "update"], ["WebsitePollQuestion", "update"], + ["WebsitePollQuestion", "update"], ]); expect(database.mutationSteps()).toEqual([ - "lock:WebsitePoll", - "lock:WebsitePollQuestion", + "lock:WebsitePoll:eq:id:7", + "read:WebsitePollQuestion:eq:pollId:orderBy:id", + "lock:WebsitePollQuestion:eq:id:4", + "lock:WebsitePollQuestion:eq:id:9", "delete:WebsitePollVote", "delete:WebsitePollQuestion", "delete:WebsitePoll", @@ -241,7 +281,7 @@ describe("Content database mutation runtime partial updates", () => { ["WebsitePollQuestion", "update"], ]); expect(database.mutationSteps()).toEqual([ - "lock:WebsitePollQuestion", + "lock:WebsitePollQuestion:eq:id:7", "delete:WebsitePollVote", "delete:WebsitePollQuestion", ]); diff --git a/src/features/housekeeping/domains/content/services/mutation-runtime-database.ts b/src/features/housekeeping/domains/content/services/mutation-runtime-database.ts index cfb6a03b..84bb5f47 100644 --- a/src/features/housekeeping/domains/content/services/mutation-runtime-database.ts +++ b/src/features/housekeeping/domains/content/services/mutation-runtime-database.ts @@ -635,11 +635,18 @@ async function pollChange( .for("update"); if (!existing) throw notFound(); if (action === "delete") { - await connection + const questionIds = await connection .select({ id: WebsitePollQuestion.id }) .from(WebsitePollQuestion) .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 .delete(WebsitePollVote) .where(