diff --git a/src/features/housekeeping/domains/system/services/mutations.ts b/src/features/housekeeping/domains/system/services/mutations.ts index 33a2dabf..667ac6d5 100644 --- a/src/features/housekeeping/domains/system/services/mutations.ts +++ b/src/features/housekeeping/domains/system/services/mutations.ts @@ -922,7 +922,7 @@ async function executeRconMutation( .select({ rank: User.rank }) .from(User) .where(eq(User.id, userId)) - .limit(1); + .for("update"); if (!target) { throw new SystemMutationFailure( "NOT_FOUND", diff --git a/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts b/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts index f97c0984..f2957abd 100644 --- a/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts +++ b/src/features/housekeeping/domains/system/services/rank-mutations-production.test.ts @@ -12,6 +12,7 @@ const doubles = vi.hoisted(() => ({ createEmulatorRankInTransaction: vi.fn(), dbDelete: vi.fn(), dbExecute: vi.fn(), + dbForUpdate: vi.fn(), dbInsert: vi.fn(), dbSelect: vi.fn(), dbTransaction: vi.fn(), @@ -117,10 +118,11 @@ function updateChain(result: Promise = Promise.resolve(undefined)) { } function limitedSelection(rows: readonly unknown[]) { + doubles.dbForUpdate.mockResolvedValueOnce(rows); return { from: () => ({ where: () => ({ - for: () => Promise.resolve(rows), + for: doubles.dbForUpdate, limit: () => Promise.resolve(rows), }), }), @@ -310,6 +312,23 @@ describe("durable rank synchronization", () => { expect(doubles.rconSetRank).not.toHaveBeenCalled(); }); + it("locks the user row before committing a set-rank intent", async () => { + doubles.dbSelect.mockImplementationOnce(() => + limitedSelection([{ rank: 3 }]), + ); + doubles.dbExecute.mockResolvedValue([[{ id: 4 }]]); + doubles.rconSetRank.mockResolvedValue(true); + + const result = await systemMutationService.execute( + serviceContext(PERMS.RCON_EXECUTE), + "rcon.set-rank", + { userId: 8, rank: 4 }, + ); + + expect(result).toMatchObject({ ok: true }); + expect(doubles.dbForUpdate).toHaveBeenCalledWith("update"); + }); + it("returns the durable recovery key when set-rank RCON fails after commit", async () => { doubles.dbSelect.mockImplementationOnce(() => limitedSelection([{ rank: 3 }]),