chore: cleanup guild rpc

This commit is contained in:
Hampus Kraft 2026-02-19 01:22:26 +00:00
parent 528e4e0d7f
commit cf06cadcfc
No known key found for this signature in database
GPG Key ID: 6090864C465A454D
7 changed files with 118 additions and 114 deletions

View File

@ -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<number> {
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<GuildMember> {
const guildId = data.guild_id;
const userId = data.user_id;

View File

@ -126,6 +126,10 @@ export class GuildRepository implements IGuildRepositoryAggregate {
return await this.memberRepo.listMembers(guildId);
}
async countMembers(guildId: GuildID): Promise<number> {
return await this.memberRepo.countMembers(guildId);
}
async upsertMember(data: GuildMemberRow): Promise<GuildMember> {
return await this.memberRepo.upsertMember(data);
}

View File

@ -24,6 +24,7 @@ import type {GuildMember} from '@fluxer/api/src/models/GuildMember';
export abstract class IGuildMemberRepository {
abstract getMember(guildId: GuildID, userId: UserID): Promise<GuildMember | null>;
abstract listMembers(guildId: GuildID): Promise<Array<GuildMember>>;
abstract countMembers(guildId: GuildID): Promise<number>;
abstract upsertMember(data: GuildMemberRow): Promise<GuildMember>;
abstract deleteMember(guildId: GuildID, userId: UserID): Promise<void>;
abstract listMembersPaginated(guildId: GuildID, limit: number, afterUserId?: UserID): Promise<Array<GuildMember>>;

View File

@ -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<Channel>;
emojis: Array<GuildEmoji>;
stickers: Array<GuildSticker>;
members: Array<GuildMember>;
roles: Array<GuildRole>;
}
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<RpcResponseGuildData> {
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<RpcResponseGuildCollectionData> {
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<GuildData | null> {
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<UserData | null> {
const user = await this.userRepository.findUnique(userId);
if (!user) return null;

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<void> {
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<RpcGuildCollectionResponse>(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);
});
});

View File

@ -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);
});

View File

@ -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<typeof RpcResponseSessionData>;
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<typeof RpcResponseGuildData>;
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'),