From cf06cadcfc6c0179e7ecf5f889deff8a46e9e118 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Thu, 19 Feb 2026 01:22:26 +0000 Subject: [PATCH] chore: cleanup guild rpc --- .../repositories/GuildMemberRepository.tsx | 11 +++ .../guild/repositories/GuildRepository.tsx | 4 + .../repositories/IGuildMemberRepository.tsx | 1 + packages/api/src/rpc/RpcService.tsx | 97 +------------------ .../tests/RpcGuildMemberCountRepair.test.tsx | 97 +++++++++++++++++++ .../api/src/test/TestHarnessController.tsx | 3 +- .../schema/src/domains/rpc/RpcSchemas.tsx | 19 ---- 7 files changed, 118 insertions(+), 114 deletions(-) create mode 100644 packages/api/src/rpc/tests/RpcGuildMemberCountRepair.test.tsx diff --git a/packages/api/src/guild/repositories/GuildMemberRepository.tsx b/packages/api/src/guild/repositories/GuildMemberRepository.tsx index 08a25be0..243a16c3 100644 --- a/packages/api/src/guild/repositories/GuildMemberRepository.tsx +++ b/packages/api/src/guild/repositories/GuildMemberRepository.tsx @@ -40,6 +40,10 @@ const FETCH_GUILD_MEMBERS_BY_GUILD_ID_QUERY = GuildMembers.selectCql({ where: GuildMembers.where.eq('guild_id'), }); +const COUNT_GUILD_MEMBERS_BY_GUILD_ID_QUERY = GuildMembers.selectCountCql({ + where: GuildMembers.where.eq('guild_id'), +}); + function createPaginatedFirstPageQuery(limit: number) { return GuildMembers.selectCql({ where: GuildMembers.where.eq('guild_id'), @@ -70,6 +74,13 @@ export class GuildMemberRepository extends IGuildMemberRepository { return members.map((member) => new GuildMember(member)); } + async countMembers(guildId: GuildID): Promise { + const result = await fetchOne<{count: bigint}>(COUNT_GUILD_MEMBERS_BY_GUILD_ID_QUERY, { + guild_id: guildId, + }); + return result ? Number(result.count) : 0; + } + async upsertMember(data: GuildMemberRow, oldData?: GuildMemberRow | null): Promise { const guildId = data.guild_id; const userId = data.user_id; diff --git a/packages/api/src/guild/repositories/GuildRepository.tsx b/packages/api/src/guild/repositories/GuildRepository.tsx index afa666c7..63db56b6 100644 --- a/packages/api/src/guild/repositories/GuildRepository.tsx +++ b/packages/api/src/guild/repositories/GuildRepository.tsx @@ -126,6 +126,10 @@ export class GuildRepository implements IGuildRepositoryAggregate { return await this.memberRepo.listMembers(guildId); } + async countMembers(guildId: GuildID): Promise { + return await this.memberRepo.countMembers(guildId); + } + async upsertMember(data: GuildMemberRow): Promise { return await this.memberRepo.upsertMember(data); } diff --git a/packages/api/src/guild/repositories/IGuildMemberRepository.tsx b/packages/api/src/guild/repositories/IGuildMemberRepository.tsx index 90ee0202..dc3967d8 100644 --- a/packages/api/src/guild/repositories/IGuildMemberRepository.tsx +++ b/packages/api/src/guild/repositories/IGuildMemberRepository.tsx @@ -24,6 +24,7 @@ import type {GuildMember} from '@fluxer/api/src/models/GuildMember'; export abstract class IGuildMemberRepository { abstract getMember(guildId: GuildID, userId: UserID): Promise; abstract listMembers(guildId: GuildID): Promise>; + abstract countMembers(guildId: GuildID): Promise; abstract upsertMember(data: GuildMemberRow): Promise; abstract deleteMember(guildId: GuildID, userId: UserID): Promise; abstract listMembersPaginated(guildId: GuildID, limit: number, afterUserId?: UserID): Promise>; diff --git a/packages/api/src/rpc/RpcService.tsx b/packages/api/src/rpc/RpcService.tsx index f4f44957..4f9f6777 100644 --- a/packages/api/src/rpc/RpcService.tsx +++ b/packages/api/src/rpc/RpcService.tsx @@ -59,9 +59,7 @@ import type {AuthSession} from '@fluxer/api/src/models/AuthSession'; import type {Channel} from '@fluxer/api/src/models/Channel'; import type {FavoriteMeme} from '@fluxer/api/src/models/FavoriteMeme'; import type {Guild} from '@fluxer/api/src/models/Guild'; -import type {GuildEmoji} from '@fluxer/api/src/models/GuildEmoji'; import type {GuildMember} from '@fluxer/api/src/models/GuildMember'; -import type {GuildRole} from '@fluxer/api/src/models/GuildRole'; import type {GuildSticker} from '@fluxer/api/src/models/GuildSticker'; import type {ReadState} from '@fluxer/api/src/models/ReadState'; import type {Relationship} from '@fluxer/api/src/models/Relationship'; @@ -110,7 +108,6 @@ import type { RpcRequest, RpcResponse, RpcResponseGuildCollectionData, - RpcResponseGuildData, RpcResponseSessionData, } from '@fluxer/schema/src/domains/rpc/RpcSchemas'; import type {RelationshipResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; @@ -132,11 +129,6 @@ interface HandleSessionRequestParams { longitude?: string; } -interface HandleGuildRequestParams { - guildId: GuildID; - requestCache: RequestCache; -} - interface HandleGuildCollectionRequestParams { guildId: GuildID; collection: RpcGuildCollectionType; @@ -145,24 +137,11 @@ interface HandleGuildCollectionRequestParams { limit?: number; } -interface GetGuildDataParams { - guildId: GuildID; -} - interface GetUserDataParams { userId: UserID; includePrivateChannels?: boolean; } -interface GuildData { - guild: Guild; - channels: Array; - emojis: Array; - stickers: Array; - members: Array; - roles: Array; -} - interface UserData { user: User; settings: UserSettings | null; @@ -419,14 +398,6 @@ export class RpcService { data: {success: true}, }; } - case 'guild': - return { - type: 'guild', - data: await this.handleGuildRequest({ - guildId: createGuildID(request.guild_id), - requestCache, - }), - }; case 'guild_collection': return { type: 'guild_collection', @@ -1207,37 +1178,6 @@ export class RpcService { }; } - private async handleGuildRequest({guildId, requestCache}: HandleGuildRequestParams): Promise { - const guildData = await this.getGuildData({guildId}); - - if (!guildData) { - throw new UnknownGuildError(); - } - - const [channels, members] = await Promise.all([ - Promise.all( - guildData.channels.map((channel) => - mapChannelToResponse({ - channel, - currentUserId: null, - userCacheService: this.userCacheService, - requestCache, - }), - ), - ), - this.mapRpcGuildMembers({guildId, members: guildData.members, requestCache}), - ]); - - return { - guild: mapGuildToGuildResponse(guildData.guild), - roles: guildData.roles.map(mapGuildRoleToResponse), - channels, - emojis: guildData.emojis.map(mapGuildEmojiToResponse), - stickers: guildData.stickers.map(mapGuildStickerToResponse), - members, - }; - } - private async handleGuildCollectionRequest({ guildId, collection, @@ -1300,7 +1240,9 @@ export class RpcService { guildId: GuildID; }): Promise { const guild = await this.getGuildOrThrow(guildId); - const repairedBannerGuild = await this.repairGuildBannerHeight(guild); + const memberCount = await this.guildRepository.countMembers(guildId); + const repairedMemberCountGuild = await this.updateGuildMemberCount(guild, memberCount); + const repairedBannerGuild = await this.repairGuildBannerHeight(repairedMemberCountGuild); const repairedSplashGuild = await this.repairGuildSplashDimensions(repairedBannerGuild); const repairedEmbedSplashGuild = await this.repairGuildEmbedSplashDimensions(repairedSplashGuild); return { @@ -1427,39 +1369,6 @@ export class RpcService { }); } - private async getGuildData({guildId}: GetGuildDataParams): Promise { - const [guildResult, channels, emojis, stickers, members, roles] = await Promise.all([ - this.guildRepository.findUnique(guildId), - this.channelRepository.listGuildChannels(guildId), - this.guildRepository.listEmojis(guildId), - this.guildRepository.listStickers(guildId), - this.guildRepository.listMembers(guildId), - this.guildRepository.listRoles(guildId), - ]); - if (!guildResult) return null; - - const migratedStickers = await this.migrateGuildStickersForRpc(guildId, stickers); - - const repairedChannelRefsGuild = await this.repairDanglingChannelReferences({guild: guildResult, channels}); - const repairedBannerGuild = await this.repairGuildBannerHeight(repairedChannelRefsGuild); - const repairedSplashGuild = await this.repairGuildSplashDimensions(repairedBannerGuild); - const repairedEmbedSplashGuild = await this.repairGuildEmbedSplashDimensions(repairedSplashGuild); - const updatedGuild = await this.updateGuildMemberCount(repairedEmbedSplashGuild, members.length); - - this.repairOrphanedInvitesAndWebhooks({guild: updatedGuild, channels}).catch((error) => { - Logger.warn({guildId: guildId.toString(), error}, 'Failed to repair orphaned invites/webhooks'); - }); - - return { - guild: updatedGuild, - channels, - emojis, - stickers: migratedStickers, - members, - roles, - }; - } - private async getUserData({userId, includePrivateChannels = true}: GetUserDataParams): Promise { const user = await this.userRepository.findUnique(userId); if (!user) return null; diff --git a/packages/api/src/rpc/tests/RpcGuildMemberCountRepair.test.tsx b/packages/api/src/rpc/tests/RpcGuildMemberCountRepair.test.tsx new file mode 100644 index 00000000..be3733b0 --- /dev/null +++ b/packages/api/src/rpc/tests/RpcGuildMemberCountRepair.test.tsx @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils'; +import {createGuildID} from '@fluxer/api/src/BrandedTypes'; +import {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository'; +import {createGuild} from '@fluxer/api/src/guild/tests/GuildTestUtils'; +import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness'; +import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants'; +import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder'; +import {afterEach, beforeEach, describe, expect, test} from 'vitest'; + +interface RpcGuildCollectionResponse { + type: 'guild_collection'; + data: { + collection: 'guild'; + guild: { + id: string; + }; + }; +} + +async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise { + await createBuilder(harness, '') + .post(`/test/guilds/${guildId}/member-count`) + .body({member_count: memberCount}) + .expect(HTTP_STATUS.OK) + .execute(); +} + +describe('RpcService guild member count repair', () => { + let harness: ApiTestHarness; + + beforeEach(async () => { + harness = await createApiTestHarness(); + }); + + afterEach(async () => { + await harness?.shutdown(); + }); + + test('repairs guild member_count from guild_members count when fetching guild data', async () => { + const owner = await createTestAccount(harness); + const guild = await createGuild(harness, owner.token, 'RPC Guild Member Count Repair'); + const guildId = createGuildID(BigInt(guild.id)); + const guildRepository = new GuildRepository(); + + await setGuildMemberCount(harness, guild.id, 999); + + const staleGuild = await guildRepository.findUnique(guildId); + expect(staleGuild).toBeTruthy(); + if (!staleGuild) { + throw new Error('Expected guild to exist before RPC member_count repair'); + } + expect(staleGuild.memberCount).toBe(999); + + const rpcResponse = await createBuilder(harness, '') + .post('/test/rpc-session-init') + .body({ + type: 'guild_collection', + guild_id: guild.id, + collection: 'guild', + }) + .expect(HTTP_STATUS.OK) + .execute(); + + expect(rpcResponse.type).toBe('guild_collection'); + expect(rpcResponse.data.collection).toBe('guild'); + expect(rpcResponse.data.guild.id).toBe(guild.id); + + const repairedGuild = await guildRepository.findUnique(guildId); + expect(repairedGuild).toBeTruthy(); + if (!repairedGuild) { + throw new Error('Expected guild to exist after RPC member_count repair'); + } + + const actualMemberCount = await guildRepository.countMembers(guildId); + expect(actualMemberCount).toBe(1); + expect(repairedGuild.memberCount).toBe(actualMemberCount); + }); +}); diff --git a/packages/api/src/test/TestHarnessController.tsx b/packages/api/src/test/TestHarnessController.tsx index 4823ee58..50977db4 100644 --- a/packages/api/src/test/TestHarnessController.tsx +++ b/packages/api/src/test/TestHarnessController.tsx @@ -102,6 +102,7 @@ import {UnknownHarvestError} from '@fluxer/errors/src/domains/moderation/Unknown import {InvalidBotFlagError} from '@fluxer/errors/src/domains/oauth/InvalidBotFlagError'; import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError'; import {UnknownUserFlagError} from '@fluxer/errors/src/domains/user/UnknownUserFlagError'; +import {RpcRequest} from '@fluxer/schema/src/domains/rpc/RpcSchemas'; import {createSnowflakeFromTimestamp, snowflakeToDate} from '@fluxer/snowflake/src/Snowflake'; import * as BucketUtils from '@fluxer/snowflake/src/SnowflakeBuckets'; import type {Context} from 'hono'; @@ -3149,7 +3150,7 @@ export function TestHarnessController(app: HonoApp) { }); app.post('/test/rpc-session-init', async (ctx) => { - const request = await ctx.req.json(); + const request = RpcRequest.parse(await ctx.req.json()); const response = await ctx.get('rpcService').handleRpcRequest({request, requestCache: ctx.get('requestCache')}); return ctx.json(response); }); diff --git a/packages/schema/src/domains/rpc/RpcSchemas.tsx b/packages/schema/src/domains/rpc/RpcSchemas.tsx index 03f664ef..e5ac68c3 100644 --- a/packages/schema/src/domains/rpc/RpcSchemas.tsx +++ b/packages/schema/src/domains/rpc/RpcSchemas.tsx @@ -57,10 +57,6 @@ export const RpcRequest = z.discriminatedUnion('type', [ latitude: createStringType(1, 32).optional().describe('Client latitude for region selection'), longitude: createStringType(1, 32).optional().describe('Client longitude for region selection'), }), - z.object({ - type: z.literal('guild').describe('Request type for fetching guild data'), - guild_id: SnowflakeType.describe('ID of the guild to fetch'), - }), z.object({ type: z.literal('guild_collection').describe('Request type for fetching a single guild collection chunk'), guild_id: SnowflakeType.describe('ID of the guild to fetch'), @@ -204,17 +200,6 @@ export const RpcResponseSessionData = z.object({ export type RpcResponseSessionData = z.infer; -export const RpcResponseGuildData = z.object({ - guild: GuildResponse.describe('Guild information'), - roles: z.array(GuildRoleResponse).describe('List of roles in the guild'), - channels: z.array(ChannelResponse).describe('List of channels in the guild'), - emojis: z.array(GuildEmojiResponse).describe('List of custom emojis in the guild'), - stickers: z.array(GuildStickerResponse).describe('List of custom stickers in the guild'), - members: z.array(GuildMemberResponse).describe('List of guild members'), -}); - -export type RpcResponseGuildData = z.infer; - export const RpcResponseGuildCollectionData = z.object({ collection: RpcGuildCollectionType.describe('Guild collection returned in this response'), guild: GuildResponse.nullish().describe('Guild information'), @@ -248,10 +233,6 @@ export const RpcResponse = z.discriminatedUnion('type', [ }) .describe('Crash logging result'), }), - z.object({ - type: z.literal('guild').describe('Response type for guild data'), - data: RpcResponseGuildData.describe('Guild data'), - }), z.object({ type: z.literal('guild_collection').describe('Response type for guild collection chunks'), data: RpcResponseGuildCollectionData.describe('Guild collection chunk data'),