Database changes to make it better practise
This commit is contained in:
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user