Files
Toju/electron/migrations/1000000000003-NormalizeArrayColumns.ts

396 lines
13 KiB
TypeScript

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"`);
}
}