Database changes to make it better practise

This commit is contained in:
2026-04-02 01:32:08 +02:00
parent 5d7e045764
commit 314a26325f
36 changed files with 1453 additions and 193 deletions

View File

@@ -3,6 +3,8 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -13,6 +15,8 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(RoomChannelEntity).clear();
await dataSource.getRepository(RoomMemberEntity).clear();
await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();

View File

@@ -1,10 +1,19 @@
import { DataSource } from 'typeorm';
import { RoomEntity, MessageEntity } from '../../../entities';
import {
RoomChannelEntity,
RoomEntity,
RoomMemberEntity,
MessageEntity
} from '../../../entities';
import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await dataSource.getRepository(MessageEntity).delete({ roomId });
await dataSource.transaction(async (manager) => {
await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(MessageEntity).delete({ roomId });
});
}

View File

@@ -1,23 +1,26 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload;
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null
});
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null
});
await repo.save(entity);
await repo.save(entity);
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
});
}

View File

@@ -1,31 +1,36 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types';
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload;
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
hasPassword: room.hasPassword ? 1 : 0,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
channels: room.channels != null ? JSON.stringify(room.channels) : null,
members: room.members != null ? JSON.stringify(room.members) : null,
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
});
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
hasPassword: room.hasPassword ? 1 : 0,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
});
await repo.save(entity);
await repo.save(entity);
await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [],
members: room.members ?? []
});
});
}

View File

@@ -1,41 +1,45 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId } });
if (!existing)
return;
if (!existing)
return;
if (updates.channelId !== undefined)
existing.channelId = updates.channelId ?? null;
if (updates.channelId !== undefined)
existing.channelId = updates.channelId ?? null;
if (updates.senderId !== undefined)
existing.senderId = updates.senderId;
if (updates.senderId !== undefined)
existing.senderId = updates.senderId;
if (updates.senderName !== undefined)
existing.senderName = updates.senderName;
if (updates.senderName !== undefined)
existing.senderName = updates.senderName;
if (updates.content !== undefined)
existing.content = updates.content;
if (updates.content !== undefined)
existing.content = updates.content;
if (updates.timestamp !== undefined)
existing.timestamp = updates.timestamp;
if (updates.timestamp !== undefined)
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.reactions !== undefined)
existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null;
if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null;
await repo.save(existing);
await repo.save(existing);
if (updates.reactions !== undefined) {
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
}
});
}

View File

@@ -1,5 +1,6 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
@@ -12,20 +13,26 @@ const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull,
channels: jsonOrNull,
members: jsonOrNull
permissions: jsonOrNull
};
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: roomId } });
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } });
if (!existing)
return;
if (!existing)
return;
applyUpdates(existing, updates, ROOM_TRANSFORMS);
await repo.save(existing);
const { channels, members, ...entityUpdates } = updates;
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
await repo.save(existing);
await replaceRoomRelations(manager, roomId, {
channels,
members
});
});
}

View File

@@ -9,10 +9,14 @@ import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
import {
ReactionPayload,
RoomPayload
} from './types';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export function rowToMessage(row: MessageEntity) {
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
const isDeleted = !!row.isDeleted;
return {
@@ -24,7 +28,7 @@ export function rowToMessage(row: MessageEntity) {
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
reactions: isDeleted ? [] : reactions,
isDeleted,
replyToId: row.replyToId ?? undefined
};
@@ -49,7 +53,10 @@ export function rowToUser(row: UserEntity) {
};
}
export function rowToRoom(row: RoomEntity) {
export function rowToRoom(
row: RoomEntity,
relations: Pick<RoomPayload, 'channels' | 'members'> = { channels: [], members: [] }
) {
return {
id: row.id,
name: row.name,
@@ -65,8 +72,8 @@ export function rowToRoom(row: RoomEntity) {
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: row.channels ? JSON.parse(row.channels) : undefined,
members: row.members ? JSON.parse(row.members) : undefined,
channels: relations.channels ?? [],
members: relations.members ?? [],
sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined

View File

@@ -1,10 +1,12 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
export async function handleGetAllRooms(dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find();
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToRoom);
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
}

View File

@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } });
return row ? rowToMessage(row) : null;
if (!row) {
return null;
}
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
}

View File

@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
take: limit,
skip: offset
});
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToMessage);
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
}

View File

@@ -2,6 +2,7 @@ import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
@@ -13,6 +14,7 @@ export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataS
},
order: { timestamp: 'ASC' }
});
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToMessage);
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
}

View File

@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } });
return row ? rowToRoom(row) : null;
if (!row) {
return null;
}
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
return rowToRoom(row, relationsByRoomId.get(row.id));
}

426
electron/cqrs/relations.ts Normal file
View File

@@ -0,0 +1,426 @@
import {
DataSource,
EntityManager,
In
} from 'typeorm';
import {
ReactionEntity,
RoomChannelEntity,
RoomMemberEntity
} from '../entities';
import { ReactionPayload } from './types';
type ChannelType = 'text' | 'voice';
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
export interface RoomChannelRecord {
id: string;
name: string;
type: ChannelType;
position: number;
}
export interface RoomMemberRecord {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: RoomMemberRole;
joinedAt: number;
lastSeenAt: number;
}
interface RoomRelationRecord {
channels: RoomChannelRecord[];
members: RoomMemberRecord[];
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ChannelType, name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function memberKey(member: { id?: string; oderId?: string }): string {
return member.oderId?.trim() || member.id?.trim() || '';
}
function fallbackDisplayName(member: Partial<RoomMemberRecord>): string {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
function fallbackUsername(member: Partial<RoomMemberRecord>): string {
const base = fallbackDisplayName(member)
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
return base || member.oderId || member.id || 'user';
}
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
? value
: 'member';
}
function mergeRoomMemberRole(
existingRole: RoomMemberRole,
incomingRole: RoomMemberRole,
preferIncoming: boolean
): RoomMemberRole {
if (existingRole === incomingRole) {
return existingRole;
}
if (incomingRole === 'member' && existingRole !== 'member') {
return existingRole;
}
if (existingRole === 'member' && incomingRole !== 'member') {
return incomingRole;
}
return preferIncoming ? incomingRole : existingRole;
}
function compareRoomMembers(firstMember: RoomMemberRecord, secondMember: RoomMemberRecord): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return memberKey(firstMember).localeCompare(memberKey(secondMember));
}
function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): RoomMemberRecord | null {
const normalizedId = typeof rawMember['id'] === 'string' ? rawMember['id'].trim() : '';
const normalizedOderId = typeof rawMember['oderId'] === 'string' ? rawMember['oderId'].trim() : '';
const normalizedKey = normalizedOderId || normalizedId;
if (!normalizedKey) {
return null;
}
const lastSeenAt = isFiniteNumber(rawMember['lastSeenAt'])
? rawMember['lastSeenAt']
: isFiniteNumber(rawMember['joinedAt'])
? rawMember['joinedAt']
: now;
const joinedAt = isFiniteNumber(rawMember['joinedAt']) ? rawMember['joinedAt'] : lastSeenAt;
const username = typeof rawMember['username'] === 'string' ? rawMember['username'].trim() : '';
const displayName = typeof rawMember['displayName'] === 'string' ? rawMember['displayName'].trim() : '';
const avatarUrl = typeof rawMember['avatarUrl'] === 'string' ? rawMember['avatarUrl'].trim() : '';
const member: RoomMemberRecord = {
id: normalizedId || normalizedKey,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember['role']),
joinedAt,
lastSeenAt
};
return member;
}
function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incomingMember: RoomMemberRecord): RoomMemberRecord {
if (!existingMember) {
return incomingMember;
}
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
return {
id: existingMember.id || incomingMember.id,
oderId: incomingMember.oderId || existingMember.oderId,
username: preferIncoming
? (incomingMember.username || existingMember.username)
: (existingMember.username || incomingMember.username),
displayName: preferIncoming
? (incomingMember.displayName || existingMember.displayName)
: (existingMember.displayName || incomingMember.displayName),
avatarUrl: preferIncoming
? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
joinedAt: Math.min(existingMember.joinedAt, incomingMember.joinedAt),
lastSeenAt: Math.max(existingMember.lastSeenAt, incomingMember.lastSeenAt)
};
}
function normalizeReactionPayloads(rawReactions: unknown, messageId: string): ReactionPayload[] {
if (!Array.isArray(rawReactions)) {
return [];
}
const seen = new Set<string>();
const reactions: ReactionPayload[] = [];
for (const rawReaction of rawReactions) {
if (!rawReaction || typeof rawReaction !== 'object') {
continue;
}
const reaction = rawReaction as Record<string, unknown>;
const emoji = typeof reaction['emoji'] === 'string' ? reaction['emoji'] : '';
const userId = typeof reaction['userId'] === 'string' ? reaction['userId'] : '';
const dedupeKey = `${userId}:${emoji}`;
if (!emoji || seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
reactions.push({
id: typeof reaction['id'] === 'string' && reaction['id'].trim() ? reaction['id'] : `${messageId}:${dedupeKey}`,
messageId,
oderId: typeof reaction['oderId'] === 'string' ? reaction['oderId'] : '',
userId,
emoji,
timestamp: isFiniteNumber(reaction['timestamp']) ? reaction['timestamp'] : 0
});
}
return reactions;
}
export function normalizeRoomChannels(rawChannels: unknown): RoomChannelRecord[] {
if (!Array.isArray(rawChannels)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
const channels: RoomChannelRecord[] = [];
for (const [index, rawChannel] of rawChannels.entries()) {
if (!rawChannel || typeof rawChannel !== 'object') {
continue;
}
const channel = rawChannel as Record<string, unknown>;
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;
}
seenIds.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
export function normalizeRoomMembers(rawMembers: unknown, now: number = Date.now()): RoomMemberRecord[] {
if (!Array.isArray(rawMembers)) {
return [];
}
const membersByKey = new Map<string, RoomMemberRecord>();
for (const rawMember of rawMembers) {
if (!rawMember || typeof rawMember !== 'object') {
continue;
}
const member = normalizeRoomMember(rawMember as Record<string, unknown>, now);
if (!member) {
continue;
}
const key = memberKey(member);
membersByKey.set(key, mergeRoomMembers(membersByKey.get(key), member));
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
}
export async function replaceMessageReactions(
manager: EntityManager,
messageId: string,
rawReactions: unknown
): Promise<void> {
const repo = manager.getRepository(ReactionEntity);
await repo.delete({ messageId });
const reactions = normalizeReactionPayloads(rawReactions, messageId);
if (reactions.length === 0) {
return;
}
await repo.insert(
reactions.map((reaction) => ({
id: reaction.id,
messageId,
oderId: reaction.oderId || null,
userId: reaction.userId || null,
emoji: reaction.emoji,
timestamp: reaction.timestamp
}))
);
}
export async function loadMessageReactionsMap(
dataSource: DataSource,
messageIds: readonly string[]
): Promise<Map<string, ReactionPayload[]>> {
const groupedReactions = new Map<string, ReactionPayload[]>();
if (messageIds.length === 0) {
return groupedReactions;
}
const rows = await dataSource.getRepository(ReactionEntity).find({
where: { messageId: In([...messageIds]) }
});
for (const row of rows) {
const reactions = groupedReactions.get(row.messageId) ?? [];
reactions.push({
id: row.id,
messageId: row.messageId,
oderId: row.oderId ?? '',
userId: row.userId ?? '',
emoji: row.emoji,
timestamp: row.timestamp
});
groupedReactions.set(row.messageId, reactions);
}
for (const reactions of groupedReactions.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
}
return groupedReactions;
}
export async function replaceRoomRelations(
manager: EntityManager,
roomId: string,
options: { channels?: unknown; members?: unknown }
): Promise<void> {
if (options.channels !== undefined) {
const channelRepo = manager.getRepository(RoomChannelEntity);
const channels = normalizeRoomChannels(options.channels);
await channelRepo.delete({ roomId });
if (channels.length > 0) {
await channelRepo.insert(
channels.map((channel) => ({
roomId,
channelId: channel.id,
name: channel.name,
type: channel.type,
position: channel.position
}))
);
}
}
if (options.members !== undefined) {
const memberRepo = manager.getRepository(RoomMemberEntity);
const members = normalizeRoomMembers(options.members);
await memberRepo.delete({ roomId });
if (members.length > 0) {
await memberRepo.insert(
members.map((member) => ({
roomId,
memberKey: memberKey(member),
id: member.id,
oderId: member.oderId ?? null,
username: member.username,
displayName: member.displayName,
avatarUrl: member.avatarUrl ?? null,
role: member.role,
joinedAt: member.joinedAt,
lastSeenAt: member.lastSeenAt
}))
);
}
}
}
export async function loadRoomRelationsMap(
dataSource: DataSource,
roomIds: readonly string[]
): Promise<Map<string, RoomRelationRecord>> {
const groupedRelations = new Map<string, RoomRelationRecord>();
if (roomIds.length === 0) {
return groupedRelations;
}
const [channelRows, memberRows] = await Promise.all([
dataSource.getRepository(RoomChannelEntity).find({
where: { roomId: In([...roomIds]) }
}),
dataSource.getRepository(RoomMemberEntity).find({
where: { roomId: In([...roomIds]) }
})
]);
for (const row of channelRows) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
relation.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
position: row.position
});
groupedRelations.set(row.roomId, relation);
}
for (const row of memberRows) {
const relation = groupedRelations.get(row.roomId) ?? { channels: [], members: [] };
relation.members.push({
id: row.id,
oderId: row.oderId ?? undefined,
username: row.username,
displayName: row.displayName,
avatarUrl: row.avatarUrl ?? undefined,
role: row.role,
joinedAt: row.joinedAt,
lastSeenAt: row.lastSeenAt
});
groupedRelations.set(row.roomId, relation);
}
for (const relation of groupedRelations.values()) {
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
relation.members.sort(compareRoomMembers);
}
return groupedRelations;
}

View File

@@ -7,6 +7,8 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -40,6 +42,8 @@ export async function initializeDatabase(): Promise<void> {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,

View File

@@ -30,9 +30,6 @@ export class MessageEntity {
@Column('integer', { nullable: true })
editedAt!: number | null;
@Column('text', { default: '[]' })
reactions!: string;
@Column('integer', { default: 0 })
isDeleted!: number;

View File

@@ -0,0 +1,23 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('room_channels')
export class RoomChannelEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@Column('text')
name!: string;
@Column('text')
type!: 'text' | 'voice';
@Column('integer')
position!: number;
}

View File

@@ -48,12 +48,6 @@ export class RoomEntity {
@Column('text', { nullable: true })
permissions!: string | null;
@Column('text', { nullable: true })
channels!: string | null;
@Column('text', { nullable: true })
members!: string | null;
@Column('text', { nullable: true })
sourceId!: string | null;

View File

@@ -0,0 +1,38 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('room_members')
export class RoomMemberEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
memberKey!: string;
@Column('text')
id!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text')
username!: string;
@Column('text')
displayName!: string;
@Column('text', { nullable: true })
avatarUrl!: string | null;
@Column('text')
role!: 'host' | 'admin' | 'moderator' | 'member';
@Column('integer')
joinedAt!: number;
@Column('integer')
lastSeenAt!: number;
}

View File

@@ -1,6 +1,8 @@
export { MessageEntity } from './MessageEntity';
export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity';
export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';

View File

@@ -0,0 +1,396 @@
import { randomUUID } from 'crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyMessageRow = {
id: string;
reactions: string | null;
};
type LegacyRoomRow = {
id: string;
channels: string | null;
members: string | null;
};
type ChannelType = 'text' | 'voice';
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
type LegacyReaction = {
id?: unknown;
oderId?: unknown;
userId?: unknown;
emoji?: unknown;
timestamp?: unknown;
};
type LegacyRoomChannel = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
type LegacyRoomMember = {
id?: unknown;
oderId?: unknown;
username?: unknown;
displayName?: unknown;
avatarUrl?: unknown;
role?: unknown;
joinedAt?: unknown;
lastSeenAt?: unknown;
};
function parseArray<T>(raw: string | null): T[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ChannelType, name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function memberKey(member: { id?: string; oderId?: string }): string {
return member.oderId?.trim() || member.id?.trim() || '';
}
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
const base = fallbackDisplayName(member)
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
return base || member.oderId || member.id || 'user';
}
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
? value
: 'member';
}
function mergeRoomMemberRole(
existingRole: RoomMemberRole,
incomingRole: RoomMemberRole,
preferIncoming: boolean
): RoomMemberRole {
if (existingRole === incomingRole) {
return existingRole;
}
if (incomingRole === 'member' && existingRole !== 'member') {
return existingRole;
}
if (existingRole === 'member' && incomingRole !== 'member') {
return incomingRole;
}
return preferIncoming ? incomingRole : existingRole;
}
function compareRoomMembers(
firstMember: {
id: string;
oderId?: string;
displayName: string;
},
secondMember: {
id: string;
oderId?: string;
displayName: string;
}
): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return memberKey(firstMember).localeCompare(memberKey(secondMember));
}
function normalizeMessageReactions(messageId: string, raw: string | null) {
const reactions = parseArray<LegacyReaction>(raw);
const seen = new Set<string>();
return reactions.flatMap((reaction) => {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
const dedupeKey = `${userId}:${emoji}`;
if (!emoji || seen.has(dedupeKey)) {
return [];
}
seen.add(dedupeKey);
return [{
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
messageId,
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
userId: userId || null,
emoji,
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
}];
});
}
function normalizeRoomChannels(raw: string | null) {
const channels = parseArray<LegacyRoomChannel>(raw);
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return channels.flatMap((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = isFiniteNumber(channel.position) ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return [];
}
seenIds.add(id);
seenNames.add(nameKey);
return [{
channelId: id,
name,
type,
position
}];
});
}
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
const members = parseArray<LegacyRoomMember>(raw);
const membersByKey = new Map<string, {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: RoomMemberRole;
joinedAt: number;
lastSeenAt: number;
}>();
for (const rawMember of members) {
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
const key = normalizedOderId || normalizedId;
if (!key) {
continue;
}
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
? rawMember.lastSeenAt
: isFiniteNumber(rawMember.joinedAt)
? rawMember.joinedAt
: now;
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
const nextMember = {
id: normalizedId || key,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember.role),
joinedAt,
lastSeenAt
};
const existingMember = membersByKey.get(key);
if (!existingMember) {
membersByKey.set(key, nextMember);
continue;
}
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
membersByKey.set(key, {
id: existingMember.id || nextMember.id,
oderId: nextMember.oderId || existingMember.oderId,
username: preferIncoming
? (nextMember.username || existingMember.username)
: (existingMember.username || nextMember.username),
displayName: preferIncoming
? (nextMember.displayName || existingMember.displayName)
: (existingMember.displayName || nextMember.displayName),
avatarUrl: preferIncoming
? (nextMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || nextMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
});
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
}
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
name = 'NormalizeArrayColumns1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channels" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"position" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "channelId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_members" (
"roomId" TEXT NOT NULL,
"memberKey" TEXT NOT NULL,
"id" TEXT NOT NULL,
"oderId" TEXT,
"username" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"role" TEXT NOT NULL,
"joinedAt" INTEGER NOT NULL,
"lastSeenAt" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "memberKey")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
for (const row of messageRows) {
const reactions = normalizeMessageReactions(row.id, row.reactions);
for (const reaction of reactions) {
const existing = await queryRunner.query(
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
[reaction.messageId, reaction.userId, reaction.emoji]
) as Array<{ 1: number }>;
if (existing.length > 0) {
continue;
}
await queryRunner.query(
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
);
}
}
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
for (const row of roomRows) {
for (const channel of normalizeRoomChannels(row.channels)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
[row.id, channel.channelId, channel.name, channel.type, channel.position]
);
}
for (const member of normalizeRoomMembers(row.members)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
memberKey(member),
member.id,
member.oderId ?? null,
member.username,
member.displayName,
member.avatarUrl ?? null,
member.role,
member.joinedAt,
member.lastSeenAt
]
);
}
}
await queryRunner.query(`
CREATE TABLE "messages_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"roomId" TEXT NOT NULL,
"channelId" TEXT,
"senderId" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"editedAt" INTEGER,
"isDeleted" INTEGER NOT NULL DEFAULT 0,
"replyToId" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
FROM "messages"
`);
await queryRunner.query(`DROP TABLE "messages"`);
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"permissions" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`);
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
}
}

View File

@@ -1,6 +1,8 @@
import { DataSource } from 'typeorm';
import {
ServerChannelEntity,
ServerEntity,
ServerTagEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
@@ -11,9 +13,13 @@ import { DeleteServerCommand } from '../../types';
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
const { serverId } = command.payload;
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
await dataSource.getRepository(ServerMembershipEntity).delete({ serverId });
await dataSource.getRepository(ServerInviteEntity).delete({ serverId });
await dataSource.getRepository(ServerBanEntity).delete({ serverId });
await dataSource.getRepository(ServerEntity).delete(serverId);
await dataSource.transaction(async (manager) => {
await manager.getRepository(ServerTagEntity).delete({ serverId });
await manager.getRepository(ServerChannelEntity).delete({ serverId });
await manager.getRepository(JoinRequestEntity).delete({ serverId });
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
await manager.getRepository(ServerInviteEntity).delete({ serverId });
await manager.getRepository(ServerBanEntity).delete({ serverId });
await manager.getRepository(ServerEntity).delete(serverId);
});
}

View File

@@ -1,25 +1,30 @@
import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { replaceServerRelations } from '../../relations';
import { UpsertServerCommand } from '../../types';
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ServerEntity);
const { server } = command.payload;
const entity = repo.create({
id: server.id,
name: server.name,
description: server.description ?? null,
ownerId: server.ownerId,
ownerPublicKey: server.ownerPublicKey,
passwordHash: server.passwordHash ?? null,
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
tags: JSON.stringify(server.tags),
channels: JSON.stringify(server.channels ?? []),
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(ServerEntity);
const entity = repo.create({
id: server.id,
name: server.name,
description: server.description ?? null,
ownerId: server.ownerId,
ownerPublicKey: server.ownerPublicKey,
passwordHash: server.passwordHash ?? null,
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
await repo.save(entity);
await repo.save(entity);
await replaceServerRelations(manager, server.id, {
tags: server.tags,
channels: server.channels ?? []
});
});
}

View File

@@ -3,67 +3,10 @@ import { ServerEntity } from '../entities/ServerEntity';
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
import {
AuthUserPayload,
ServerChannelPayload,
ServerPayload,
JoinRequestPayload
} from './types';
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function parseStringArray(raw: string | null | undefined): string[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string')
: [];
} catch {
return [];
}
}
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies ServerChannelPayload;
})
.filter((channel): channel is ServerChannelPayload => !!channel);
} catch {
return [];
}
}
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return {
id: row.id,
@@ -74,7 +17,10 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
};
}
export function rowToServer(row: ServerEntity): ServerPayload {
export function rowToServer(
row: ServerEntity,
relations: Pick<ServerPayload, 'tags' | 'channels'> = { tags: [], channels: [] }
): ServerPayload {
return {
id: row.id,
name: row.name,
@@ -86,8 +32,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
tags: parseStringArray(row.tags),
channels: parseServerChannels(row.channels),
tags: relations.tags ?? [],
channels: relations.channels ?? [],
createdAt: row.createdAt,
lastSeen: row.lastSeen
};

View File

@@ -1,10 +1,12 @@
import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
export async function handleGetAllPublicServers(dataSource: DataSource) {
const repo = dataSource.getRepository(ServerEntity);
const rows = await repo.find({ where: { isPrivate: 0 } });
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToServer);
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
}

View File

@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { GetServerByIdQuery } from '../../types';
import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(ServerEntity);
const row = await repo.findOne({ where: { id: query.payload.serverId } });
return row ? rowToServer(row) : null;
if (!row) {
return null;
}
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
return rowToServer(row, relationsByServerId.get(row.id));
}

View File

@@ -0,0 +1,160 @@
import {
DataSource,
EntityManager,
In
} from 'typeorm';
import {
ServerChannelEntity,
ServerTagEntity
} from '../entities';
import { ServerChannelPayload } from './types';
interface ServerRelationRecord {
tags: string[];
channels: ServerChannelPayload[];
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
export function normalizeServerTags(rawTags: unknown): string[] {
if (!Array.isArray(rawTags)) {
return [];
}
return rawTags.filter((tag): tag is string => typeof tag === 'string');
}
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
if (!Array.isArray(rawChannels)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, rawChannel] of rawChannels.entries()) {
if (!rawChannel || typeof rawChannel !== 'object') {
continue;
}
const channel = rawChannel as Record<string, unknown>;
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;
}
seenIds.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
export async function replaceServerRelations(
manager: EntityManager,
serverId: string,
options: { tags: unknown; channels: unknown }
): Promise<void> {
const tagRepo = manager.getRepository(ServerTagEntity);
const channelRepo = manager.getRepository(ServerChannelEntity);
const tags = normalizeServerTags(options.tags);
const channels = normalizeServerChannels(options.channels);
await tagRepo.delete({ serverId });
await channelRepo.delete({ serverId });
if (tags.length > 0) {
await tagRepo.insert(
tags.map((tag, position) => ({
serverId,
position,
value: tag
}))
);
}
if (channels.length > 0) {
await channelRepo.insert(
channels.map((channel) => ({
serverId,
channelId: channel.id,
name: channel.name,
type: channel.type,
position: channel.position
}))
);
}
}
export async function loadServerRelationsMap(
dataSource: DataSource,
serverIds: readonly string[]
): Promise<Map<string, ServerRelationRecord>> {
const groupedRelations = new Map<string, ServerRelationRecord>();
if (serverIds.length === 0) {
return groupedRelations;
}
const [tagRows, channelRows] = await Promise.all([
dataSource.getRepository(ServerTagEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelEntity).find({
where: { serverId: In([...serverIds]) }
})
]);
for (const row of tagRows) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
relation.tags.push(row.value);
groupedRelations.set(row.serverId, relation);
}
for (const row of channelRows) {
const relation = groupedRelations.get(row.serverId) ?? { tags: [], channels: [] };
relation.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
position: row.position
});
groupedRelations.set(row.serverId, relation);
}
for (const [serverId, relation] of groupedRelations) {
const orderedTags = tagRows
.filter((row) => row.serverId === serverId)
.sort((first, second) => first.position - second.position)
.map((row) => row.value);
relation.tags = orderedTags;
relation.channels.sort((first, second) => first.position - second.position || first.name.localeCompare(second.name));
}
return groupedRelations;
}

View File

@@ -4,6 +4,8 @@ import { DataSource } from 'typeorm';
import {
AuthUserEntity,
ServerEntity,
ServerTagEntity,
ServerChannelEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
@@ -54,6 +56,8 @@ export async function initDatabase(): Promise<void> {
entities: [
AuthUserEntity,
ServerEntity,
ServerTagEntity,
ServerChannelEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,

View File

@@ -0,0 +1,23 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('server_channels')
export class ServerChannelEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
channelId!: string;
@Column('text')
name!: string;
@Column('text')
type!: 'text' | 'voice';
@Column('integer')
position!: number;
}

View File

@@ -33,12 +33,6 @@ export class ServerEntity {
@Column('integer', { default: 0 })
currentUsers!: number;
@Column('text', { default: '[]' })
tags!: string;
@Column('text', { default: '[]' })
channels!: string;
@Column('integer')
createdAt!: number;

View File

@@ -0,0 +1,17 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('server_tags')
export class ServerTagEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('integer')
position!: number;
@Column('text')
value!: string;
}

View File

@@ -1,5 +1,7 @@
export { AuthUserEntity } from './AuthUserEntity';
export { ServerEntity } from './ServerEntity';
export { ServerTagEntity } from './ServerTagEntity';
export { ServerChannelEntity } from './ServerChannelEntity';
export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity';

View File

@@ -0,0 +1,142 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyServerRow = {
id: string;
tags: string | null;
channels: string | null;
};
type LegacyServerChannel = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
function parseArray<T>(raw: string | null): T[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: 'text' | 'voice', name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeServerTags(raw: string | null): string[] {
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
}
function normalizeServerChannels(raw: string | null) {
const channels = parseArray<LegacyServerChannel>(raw);
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return channels.flatMap((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = isFiniteNumber(channel.position) ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return [];
}
seenIds.add(id);
seenNames.add(nameKey);
return [{
channelId: id,
name,
type,
position
}];
});
}
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
name = 'NormalizeServerArrays1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_tags" (
"serverId" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("serverId", "position")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_channels" (
"serverId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"position" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "channelId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
[row.id, position, tag]
);
}
for (const channel of normalizeServerChannels(row.channels)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
[row.id, channel.channelId, channel.name, channel.type, channel.position]
);
}
}
await queryRunner.query(`
CREATE TABLE "servers_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"ownerPublicKey" TEXT NOT NULL,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL,
"passwordHash" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
FROM "servers"
`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
}
}

View File

@@ -2,10 +2,12 @@ import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
export const serverMigrations = [
InitialSchema1000000000000,
ServerAccessControl1000000000001,
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003
RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004
];

View File

@@ -1,6 +1,5 @@
import { Router } from 'express';
import { getUserById } from '../cqrs';
import { rowToServer } from '../cqrs/mappers';
import { ServerPayload } from '../cqrs/types';
import { getActiveServerInvite } from '../services/server-access.service';
import {
@@ -283,7 +282,7 @@ invitesApiRouter.get('/:id', async (req, res) => {
return res.status(404).json({ error: 'Invite link has expired or is invalid', errorCode: 'INVITE_EXPIRED' });
}
const server = rowToServer(bundle.server);
const server = bundle.server;
res.json({
id: bundle.invite.id,
@@ -315,7 +314,7 @@ invitePageRouter.get('/:id', async (req, res) => {
return;
}
const server = rowToServer(bundle.server);
const server = bundle.server;
const owner = await getUserById(server.ownerId);
res.send(renderInvitePage({

View File

@@ -8,6 +8,7 @@ import {
ServerMembershipEntity
} from '../entities';
import { rowToServer } from '../cqrs/mappers';
import { loadServerRelationsMap } from '../cqrs/relations';
import { ServerPayload } from '../cqrs/types';
export const SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
@@ -57,6 +58,12 @@ function getBanRepository() {
return getDataSource().getRepository(ServerBanEntity);
}
async function toServerPayload(server: ServerEntity): Promise<ServerPayload> {
const relationsByServerId = await loadServerRelationsMap(getDataSource(), [server.id]);
return rowToServer(server, relationsByServerId.get(server.id));
}
function normalizePassword(password?: string | null): string | null {
const normalized = password?.trim() ?? '';
@@ -194,7 +201,7 @@ export async function createServerInvite(
export async function getActiveServerInvite(
inviteId: string
): Promise<{ invite: ServerInviteEntity; server: ServerEntity } | null> {
): Promise<{ invite: ServerInviteEntity; server: ServerPayload } | null> {
await pruneExpiredServerAccessArtifacts();
const invite = await getInviteRepository().findOne({ where: { id: inviteId } });
@@ -214,7 +221,10 @@ export async function getActiveServerInvite(
return null;
}
return { invite, server };
return {
invite,
server: await toServerPayload(server)
};
}
export async function joinServerWithAccess(options: {
@@ -242,7 +252,7 @@ export async function joinServerWithAccess(options: {
return {
joinedBefore: !!existingMembership,
server: rowToServer(server),
server: await toServerPayload(server),
via: 'membership'
};
}
@@ -260,7 +270,7 @@ export async function joinServerWithAccess(options: {
return {
joinedBefore: !!existingMembership,
server: rowToServer(server),
server: await toServerPayload(server),
via: 'invite'
};
}
@@ -272,7 +282,7 @@ export async function joinServerWithAccess(options: {
return {
joinedBefore: true,
server: rowToServer(server),
server: await toServerPayload(server),
via: 'membership'
};
}
@@ -288,7 +298,7 @@ export async function joinServerWithAccess(options: {
return {
joinedBefore: false,
server: rowToServer(server),
server: await toServerPayload(server),
via: 'password'
};
}
@@ -301,7 +311,7 @@ export async function joinServerWithAccess(options: {
return {
joinedBefore: false,
server: rowToServer(server),
server: await toServerPayload(server),
via: 'public'
};
}

View File

@@ -77,6 +77,8 @@ sequenceDiagram
The renderer sends structured command/query objects through the Electron preload bridge. The main process handles them with TypeORM against a local SQLite file. No database logic runs in the renderer.
The Electron schema now normalises reaction rows and room channel/member rosters into separate SQLite tables instead of storing those arrays inline on the parent message or room rows. The renderer-facing API is unchanged: CQRS handlers rehydrate the same `Message` and `Room` payloads before returning them over IPC.
```mermaid
sequenceDiagram
participant Eff as NgRx Effect