fix: harden data integrity, connection stability, and memory management
Wrap all multi-write DB operations in atomic transactions (server/channel creation, message+attachment linking, DM creation, friend acceptance, cascading deletes) to prevent partial-write corruption. Batch N+1 queries in WS ready payload into O(1) bulk fetches with chunked inArray() to respect SQLite's variable limit. Fix chat history regression where background WS messages bypassed channel load by switching the guard from messages.has() to hasMore.has(). Add LRU channel eviction (20 cached, evict to 15) and per-channel message cap (200) to bound client memory growth. Shorten WS heartbeat from 30s to 15s for aggressive proxy/NAT environments. Clear all user-scoped stores on logout to prevent cross-session data leaks. Extract LiveKit internal accessors into shared livekitInternals utility.
This commit is contained in:
@@ -374,24 +374,26 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new DM channel
|
// Create new DM channel with both members atomically
|
||||||
const dmChannelId = generateSnowflake();
|
const dmChannelId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
db.insert(schema.dmChannels).values({
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schema.dmChannels).values({
|
||||||
id: dmChannelId,
|
id: dmChannelId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
db.insert(schema.dmMembers).values({
|
tx.insert(schema.dmMembers).values({
|
||||||
dmChannelId,
|
dmChannelId,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
db.insert(schema.dmMembers).values({
|
tx.insert(schema.dmMembers).values({
|
||||||
dmChannelId,
|
dmChannelId,
|
||||||
userId,
|
userId,
|
||||||
}).run();
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
const currentUserRow = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
const members = [currentUserRow, targetUser]
|
const members = [currentUserRow, targetUser]
|
||||||
@@ -791,7 +793,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
db.insert(schema.dmMessages).values({
|
// Insert message and link attachments atomically
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schema.dmMessages).values({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
dmChannelId: id,
|
dmChannelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
@@ -800,15 +804,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
// Link attachments to this DM message
|
|
||||||
if (attachmentIds && attachmentIds.length > 0) {
|
if (attachmentIds && attachmentIds.length > 0) {
|
||||||
for (const attId of attachmentIds) {
|
for (const attId of attachmentIds) {
|
||||||
db.update(schema.attachments)
|
tx.update(schema.attachments)
|
||||||
.set({ dmMessageId: messageId })
|
.set({ dmMessageId: messageId })
|
||||||
.where(eq(schema.attachments.id, attId))
|
.where(eq(schema.attachments.id, attId))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const message = getDmMessageWithUser(messageId);
|
const message = getDmMessageWithUser(messageId);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@@ -886,20 +890,20 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
|
return reply.code(403).send({ error: 'You can only delete your own messages', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete attachments linked to this DM message
|
// Delete attachments, reactions, and message atomically
|
||||||
db.delete(schema.attachments)
|
db.transaction((tx) => {
|
||||||
|
tx.delete(schema.attachments)
|
||||||
.where(eq(schema.attachments.dmMessageId, id))
|
.where(eq(schema.attachments.dmMessageId, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Delete reactions
|
tx.delete(schema.dmReactions)
|
||||||
db.delete(schema.dmReactions)
|
|
||||||
.where(eq(schema.dmReactions.dmMessageId, id))
|
.where(eq(schema.dmReactions.dmMessageId, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Delete message
|
tx.delete(schema.dmMessages)
|
||||||
db.delete(schema.dmMessages)
|
|
||||||
.where(eq(schema.dmMessages.id, id))
|
.where(eq(schema.dmMessages.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
// Broadcast to all DM members
|
// Broadcast to all DM members
|
||||||
const dmMembers = db.select()
|
const dmMembers = db.select()
|
||||||
|
|||||||
@@ -274,7 +274,9 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
db.insert(schema.messages).values({
|
// Insert message and link attachments atomically
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schema.messages).values({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
channelId: id,
|
channelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
@@ -283,15 +285,15 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
// Link attachments to message
|
|
||||||
if (attachmentIds && attachmentIds.length > 0) {
|
if (attachmentIds && attachmentIds.length > 0) {
|
||||||
for (const attId of attachmentIds) {
|
for (const attId of attachmentIds) {
|
||||||
db.update(schema.attachments)
|
tx.update(schema.attachments)
|
||||||
.set({ messageId })
|
.set({ messageId })
|
||||||
.where(eq(schema.attachments.id, attId))
|
.where(eq(schema.attachments.id, attId))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
const user = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -415,9 +417,11 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
|
return reply.code(403).send({ error: 'You cannot delete this message', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete attachments then message
|
// Delete attachments and message atomically
|
||||||
db.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
|
db.transaction((tx) => {
|
||||||
db.delete(schema.messages).where(eq(schema.messages.id, id)).run();
|
tx.delete(schema.attachments).where(eq(schema.attachments.messageId, id)).run();
|
||||||
|
tx.delete(schema.messages).where(eq(schema.messages.id, id)).run();
|
||||||
|
});
|
||||||
|
|
||||||
// Broadcast deletion
|
// Broadcast deletion
|
||||||
connectionManager.sendToServer(serverId, {
|
connectionManager.sendToServer(serverId, {
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const inviteCode = generateInviteCode();
|
const inviteCode = generateInviteCode();
|
||||||
|
|
||||||
// Create the server
|
// Create server, owner membership, and default channel atomically
|
||||||
db.insert(schema.servers).values({
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schema.servers).values({
|
||||||
id: serverId,
|
id: serverId,
|
||||||
name: trimmedName,
|
name: trimmedName,
|
||||||
icon: icon ?? null,
|
icon: icon ?? null,
|
||||||
@@ -90,16 +91,14 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
// Add owner as member with 'owner' role
|
tx.insert(schema.serverMembers).values({
|
||||||
db.insert(schema.serverMembers).values({
|
|
||||||
serverId,
|
serverId,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
role: 'owner',
|
role: 'owner',
|
||||||
joinedAt: now,
|
joinedAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
// Create default #general text channel
|
tx.insert(schema.channels).values({
|
||||||
db.insert(schema.channels).values({
|
|
||||||
id: channelId,
|
id: channelId,
|
||||||
serverId,
|
serverId,
|
||||||
name: 'general',
|
name: 'general',
|
||||||
@@ -107,6 +106,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
position: 0,
|
position: 0,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
});
|
||||||
|
|
||||||
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
|
const server = db.select().from(schema.servers).where(eq(schema.servers.id, serverId)).get();
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -302,10 +302,12 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
|
return reply.code(403).send({ error: 'Only the server owner can delete the server', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all channels (messages cascade), members, then server
|
// Delete all channels (messages cascade), members, then server atomically
|
||||||
db.delete(schema.channels).where(eq(schema.channels.serverId, id)).run();
|
db.transaction((tx) => {
|
||||||
db.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run();
|
tx.delete(schema.channels).where(eq(schema.channels.serverId, id)).run();
|
||||||
db.delete(schema.servers).where(eq(schema.servers.id, id)).run();
|
tx.delete(schema.serverMembers).where(eq(schema.serverMembers.serverId, id)).run();
|
||||||
|
tx.delete(schema.servers).where(eq(schema.servers.id, id)).run();
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,15 +207,22 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'accepted') {
|
if (status === 'accepted') {
|
||||||
// Add to friends table
|
// Insert friend and update request status atomically
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
db.insert(schema.friends).values({
|
db.transaction((tx) => {
|
||||||
|
tx.insert(schema.friends).values({
|
||||||
userId: friendRequest.fromId,
|
userId: friendRequest.fromId,
|
||||||
friendId: friendRequest.toId,
|
friendId: friendRequest.toId,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
// Get the accepting user's data for the WS event
|
tx.update(schema.friendRequests)
|
||||||
|
.set({ status })
|
||||||
|
.where(eq(schema.friendRequests.id, id))
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
// WS broadcast AFTER transaction commits
|
||||||
const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
const acceptingUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||||
if (acceptingUser) {
|
if (acceptingUser) {
|
||||||
const friend: Friend = {
|
const friend: Friend = {
|
||||||
@@ -228,13 +235,13 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
requestId: id,
|
requestId: id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
// For declined, just update the status (single write, no transaction needed)
|
||||||
// Update request status
|
|
||||||
db.update(schema.friendRequests)
|
db.update(schema.friendRequests)
|
||||||
.set({ status })
|
.set({ status })
|
||||||
.where(eq(schema.friendRequests.id, id))
|
.where(eq(schema.friendRequests.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import { verifyJwt } from '../utils/auth.js';
|
import { verifyJwt } from '../utils/auth.js';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { eq, inArray, desc } from 'drizzle-orm';
|
import { eq, inArray, desc, sql } from 'drizzle-orm';
|
||||||
import { handleClientEvent } from './events.js';
|
import { handleClientEvent } from './events.js';
|
||||||
import type {
|
import type {
|
||||||
User,
|
User,
|
||||||
@@ -16,6 +16,19 @@ import type {
|
|||||||
ActiveCallInfo,
|
ActiveCallInfo,
|
||||||
} from '@opencord/shared';
|
} from '@opencord/shared';
|
||||||
|
|
||||||
|
// SQLite's SQLITE_MAX_VARIABLE_NUMBER default is 999.
|
||||||
|
// Chunk inArray() calls to stay safely under this limit.
|
||||||
|
const BATCH_CHUNK_SIZE = 500;
|
||||||
|
|
||||||
|
function batchInArray<TId, TResult>(ids: TId[], queryFn: (chunk: TId[]) => TResult[]): TResult[] {
|
||||||
|
if (ids.length <= BATCH_CHUNK_SIZE) return queryFn(ids);
|
||||||
|
const results: TResult[] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += BATCH_CHUNK_SIZE) {
|
||||||
|
results.push(...queryFn(ids.slice(i, i + BATCH_CHUNK_SIZE)));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -495,11 +508,36 @@ function buildReadyPayload(userId: string): {
|
|||||||
.where(inArray(schema.servers.id, serverIds))
|
.where(inArray(schema.servers.id, serverIds))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
// Batch: all channels for all servers (1 query instead of N)
|
||||||
|
const allChannels = batchInArray(
|
||||||
|
serverIds,
|
||||||
|
ids => db.select().from(schema.channels).where(inArray(schema.channels.serverId, ids)).all(),
|
||||||
|
);
|
||||||
|
const channelsByServer = new Map<string, (typeof allChannels)>();
|
||||||
|
for (const ch of allChannels) {
|
||||||
|
let arr = channelsByServer.get(ch.serverId);
|
||||||
|
if (!arr) { arr = []; channelsByServer.set(ch.serverId, arr); }
|
||||||
|
arr.push(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch: last message ID per channel (1 query instead of N×C)
|
||||||
|
const allChannelIds = allChannels.map(ch => ch.id);
|
||||||
|
const lastMsgMap = new Map<string, string>();
|
||||||
|
if (allChannelIds.length > 0) {
|
||||||
|
const lastMsgRows = batchInArray(
|
||||||
|
allChannelIds,
|
||||||
|
ids => db.select({
|
||||||
|
channelId: schema.messages.channelId,
|
||||||
|
lastId: sql<string>`max(${schema.messages.id})`,
|
||||||
|
}).from(schema.messages).where(inArray(schema.messages.channelId, ids)).groupBy(schema.messages.channelId).all(),
|
||||||
|
);
|
||||||
|
for (const row of lastMsgRows) {
|
||||||
|
if (row.lastId) lastMsgMap.set(row.channelId, row.lastId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const serverRow of serverRows) {
|
for (const serverRow of serverRows) {
|
||||||
const channels = db.select()
|
const channels = channelsByServer.get(serverRow.id) ?? [];
|
||||||
.from(schema.channels)
|
|
||||||
.where(eq(schema.channels.serverId, serverRow.id))
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const roles = db.select()
|
const roles = db.select()
|
||||||
.from(schema.roles)
|
.from(schema.roles)
|
||||||
@@ -514,7 +552,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
|
|
||||||
const memberUserIds = memberRows.map(m => m.userId);
|
const memberUserIds = memberRows.map(m => m.userId);
|
||||||
const users = memberUserIds.length > 0
|
const users = memberUserIds.length > 0
|
||||||
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
? batchInArray(memberUserIds, ids => db.select().from(schema.users).where(inArray(schema.users.id, ids)).all())
|
||||||
: [];
|
: [];
|
||||||
const userMap = new Map(users.map(u => [u.id, u]));
|
const userMap = new Map(users.map(u => [u.id, u]));
|
||||||
|
|
||||||
@@ -562,14 +600,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
ownerId: serverRow.ownerId,
|
ownerId: serverRow.ownerId,
|
||||||
inviteCode: serverRow.inviteCode,
|
inviteCode: serverRow.inviteCode,
|
||||||
createdAt: serverRow.createdAt,
|
createdAt: serverRow.createdAt,
|
||||||
channels: channels.map(ch => {
|
channels: channels.map(ch => ({
|
||||||
const lastMsg = db.select({ id: schema.messages.id })
|
|
||||||
.from(schema.messages)
|
|
||||||
.where(eq(schema.messages.channelId, ch.id))
|
|
||||||
.orderBy(desc(schema.messages.createdAt))
|
|
||||||
.limit(1)
|
|
||||||
.get();
|
|
||||||
return {
|
|
||||||
id: ch.id,
|
id: ch.id,
|
||||||
serverId: ch.serverId,
|
serverId: ch.serverId,
|
||||||
name: ch.name,
|
name: ch.name,
|
||||||
@@ -577,9 +608,8 @@ function buildReadyPayload(userId: string): {
|
|||||||
topic: ch.topic,
|
topic: ch.topic,
|
||||||
position: ch.position ?? 0,
|
position: ch.position ?? 0,
|
||||||
createdAt: ch.createdAt,
|
createdAt: ch.createdAt,
|
||||||
lastMessageId: lastMsg?.id ?? null,
|
lastMessageId: lastMsgMap.get(ch.id) ?? null,
|
||||||
};
|
})),
|
||||||
}),
|
|
||||||
members,
|
members,
|
||||||
roles: roles.map(r => ({
|
roles: roles.map(r => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -602,39 +632,61 @@ function buildReadyPayload(userId: string): {
|
|||||||
.where(eq(schema.dmMembers.userId, userId))
|
.where(eq(schema.dmMembers.userId, userId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const dmChannelIds = dmMemberships.map(dm => dm.dmChannelId);
|
||||||
const dmChannels: DmChannel[] = [];
|
const dmChannels: DmChannel[] = [];
|
||||||
|
|
||||||
for (const dm of dmMemberships) {
|
if (dmChannelIds.length > 0) {
|
||||||
const dmChannel = db.select()
|
// Batch: all DM channels (1 query)
|
||||||
.from(schema.dmChannels)
|
const allDmChannelRows = batchInArray(
|
||||||
.where(eq(schema.dmChannels.id, dm.dmChannelId))
|
dmChannelIds,
|
||||||
.get();
|
ids => db.select().from(schema.dmChannels).where(inArray(schema.dmChannels.id, ids)).all(),
|
||||||
|
);
|
||||||
|
const dmChannelMap = new Map(allDmChannelRows.map(c => [c.id, c]));
|
||||||
|
|
||||||
|
// Batch: all DM members across all channels (1 query)
|
||||||
|
const allDmMemberRows = batchInArray(
|
||||||
|
dmChannelIds,
|
||||||
|
ids => db.select().from(schema.dmMembers).where(inArray(schema.dmMembers.dmChannelId, ids)).all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch: all unique users from DM members (1 query)
|
||||||
|
const allDmUserIds = [...new Set(allDmMemberRows.map(m => m.userId))];
|
||||||
|
const allDmUsers = allDmUserIds.length > 0
|
||||||
|
? batchInArray(allDmUserIds, ids => db.select().from(schema.users).where(inArray(schema.users.id, ids)).all())
|
||||||
|
: [];
|
||||||
|
const dmUserMap = new Map(allDmUsers.map(u => [u.id, u]));
|
||||||
|
|
||||||
|
// Batch: last message per DM channel (1 query — fixes the full-table-scan bug)
|
||||||
|
const dmLastMsgIdRows = batchInArray(
|
||||||
|
dmChannelIds,
|
||||||
|
ids => db.select({
|
||||||
|
dmChannelId: schema.dmMessages.dmChannelId,
|
||||||
|
lastId: sql<string>`max(${schema.dmMessages.id})`,
|
||||||
|
}).from(schema.dmMessages).where(inArray(schema.dmMessages.dmChannelId, ids)).groupBy(schema.dmMessages.dmChannelId).all(),
|
||||||
|
);
|
||||||
|
const dmLastMsgIds = dmLastMsgIdRows.map(r => r.lastId).filter((id): id is string => id != null);
|
||||||
|
const dmLastMessages = dmLastMsgIds.length > 0
|
||||||
|
? batchInArray(dmLastMsgIds, ids => db.select().from(schema.dmMessages).where(inArray(schema.dmMessages.id, ids)).all())
|
||||||
|
: [];
|
||||||
|
const dmLastMsgMap = new Map(dmLastMessages.map(m => [m.dmChannelId, m]));
|
||||||
|
|
||||||
|
// Assemble DM channels with zero additional queries
|
||||||
|
for (const dm of dmMemberships) {
|
||||||
|
const dmChannel = dmChannelMap.get(dm.dmChannelId);
|
||||||
if (!dmChannel) continue;
|
if (!dmChannel) continue;
|
||||||
|
|
||||||
const dmMemberRows = db.select()
|
const memberRows = allDmMemberRows.filter(m => m.dmChannelId === dm.dmChannelId);
|
||||||
.from(schema.dmMembers)
|
const members = memberRows
|
||||||
.where(eq(schema.dmMembers.dmChannelId, dm.dmChannelId))
|
.map(m => dmUserMap.get(m.userId))
|
||||||
.all();
|
.filter((u): u is NonNullable<typeof u> => u != null)
|
||||||
|
.map(sanitizeUser);
|
||||||
|
|
||||||
const dmMemberUserIds = dmMemberRows.map(m => m.userId);
|
const last = dmLastMsgMap.get(dm.dmChannelId) ?? null;
|
||||||
const dmUsers = dmMemberUserIds.length > 0
|
|
||||||
? db.select().from(schema.users).where(inArray(schema.users.id, dmMemberUserIds)).all()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Get last message
|
|
||||||
const lastMessage = db.select()
|
|
||||||
.from(schema.dmMessages)
|
|
||||||
.where(eq(schema.dmMessages.dmChannelId, dm.dmChannelId))
|
|
||||||
.orderBy(schema.dmMessages.createdAt)
|
|
||||||
.all();
|
|
||||||
|
|
||||||
const last = lastMessage.length > 0 ? lastMessage[lastMessage.length - 1] : null;
|
|
||||||
|
|
||||||
dmChannels.push({
|
dmChannels.push({
|
||||||
id: dmChannel.id,
|
id: dmChannel.id,
|
||||||
createdAt: dmChannel.createdAt,
|
createdAt: dmChannel.createdAt,
|
||||||
members: dmUsers.map(sanitizeUser),
|
members,
|
||||||
lastMessage: last ? {
|
lastMessage: last ? {
|
||||||
id: last.id,
|
id: last.id,
|
||||||
dmChannelId: last.dmChannelId,
|
dmChannelId: last.dmChannelId,
|
||||||
@@ -644,6 +696,7 @@ function buildReadyPayload(userId: string): {
|
|||||||
} : null,
|
} : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get Server Folders
|
// Get Server Folders
|
||||||
const folderRows = db.select()
|
const folderRows = db.select()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
stopScreenShare,
|
stopScreenShare,
|
||||||
handleScreenShareUnpublished,
|
handleScreenShareUnpublished,
|
||||||
} from '../utils/screenShare';
|
} from '../utils/screenShare';
|
||||||
|
import { getMediaStreamTrack } from '../utils/livekitInternals';
|
||||||
|
|
||||||
let _activeRoom: Room | null = null;
|
let _activeRoom: Room | null = null;
|
||||||
|
|
||||||
@@ -522,7 +523,7 @@ export function useLiveKit() {
|
|||||||
const opts = buildScreenShareOptions(screenShareConfig);
|
const opts = buildScreenShareOptions(screenShareConfig);
|
||||||
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||||
if (screenPub?.videoTrack) {
|
if (screenPub?.videoTrack) {
|
||||||
const mediaTrack = (screenPub.videoTrack as any).mediaStreamTrack as MediaStreamTrack;
|
const mediaTrack = getMediaStreamTrack(screenPub.videoTrack);
|
||||||
if (mediaTrack) {
|
if (mediaTrack) {
|
||||||
await mediaTrack.applyConstraints({ width: { ideal: opts.capture.width }, height: { ideal: opts.capture.height }, frameRate: { ideal: opts.capture.frameRate } });
|
await mediaTrack.applyConstraints({ width: { ideal: opts.capture.width }, height: { ideal: opts.capture.height }, frameRate: { ideal: opts.capture.frameRate } });
|
||||||
mediaTrack.contentHint = opts.contentHint;
|
mediaTrack.contentHint = opts.contentHint;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Track } from 'livekit-client';
|
import { Track } from 'livekit-client';
|
||||||
import { getActiveRoom } from './useLiveKit';
|
import { getActiveRoom } from './useLiveKit';
|
||||||
|
import { discoverPeerConnections } from '../utils/livekitInternals';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -96,40 +97,6 @@ function reportKind(report: any): 'audio' | 'video' | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
|
|
||||||
* Different livekit-client versions expose the PC at different internal paths.
|
|
||||||
*/
|
|
||||||
function discoverPeerConnections(room: any): RTCPeerConnection[] {
|
|
||||||
const engine = room?.engine;
|
|
||||||
if (!engine) return [];
|
|
||||||
|
|
||||||
const pcs: RTCPeerConnection[] = [];
|
|
||||||
const seen = new WeakSet<object>();
|
|
||||||
|
|
||||||
const tryAdd = (val: any) => {
|
|
||||||
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
|
|
||||||
seen.add(val);
|
|
||||||
pcs.push(val);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
|
|
||||||
tryAdd(engine.pcManager?.publisher?.pc);
|
|
||||||
tryAdd(engine.pcManager?.subscriber?.pc);
|
|
||||||
// Private backing field fallback
|
|
||||||
tryAdd(engine.pcManager?.publisher?._pc);
|
|
||||||
tryAdd(engine.pcManager?.subscriber?._pc);
|
|
||||||
// Older livekit-client paths
|
|
||||||
tryAdd(engine.publisher?.pc);
|
|
||||||
tryAdd(engine.subscriber?.pc);
|
|
||||||
// Unified-plan single PC
|
|
||||||
tryAdd(engine.pc);
|
|
||||||
tryAdd(room.pc);
|
|
||||||
|
|
||||||
return pcs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferSimulcastLayer(width: number | null, height: number | null): string | null {
|
function inferSimulcastLayer(width: number | null, height: number | null): string | null {
|
||||||
if (height !== null && height > 0) {
|
if (height !== null && height > 0) {
|
||||||
if (height >= 1000) return 'High';
|
if (height >= 1000) return 'High';
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ function connect(): void {
|
|||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'ping' }));
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
}
|
}
|
||||||
}, 30_000);
|
}, 15_000);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { User } from '@opencord/shared';
|
import type { User } from '@opencord/shared';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { useChatStore } from './chatStore';
|
||||||
|
import { useServerStore } from './serverStore';
|
||||||
|
import { useSocialStore } from './socialStore';
|
||||||
|
import { useVoiceStore } from './voiceStore';
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
@@ -48,6 +52,11 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('opencord_token');
|
localStorage.removeItem('opencord_token');
|
||||||
|
// Clear all user-scoped state to prevent data leaking between sessions
|
||||||
|
useChatStore.getState().clearAllMessages();
|
||||||
|
useServerStore.getState().populateFromReady([], [], []);
|
||||||
|
useSocialStore.getState().reset();
|
||||||
|
useVoiceStore.getState().clearAllVoiceUsers();
|
||||||
set({ token: null, user: null });
|
set({ token: null, user: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { wsSend } from '../hooks/useWebSocket';
|
|||||||
import { isDmChannel, useServerStore } from './serverStore';
|
import { isDmChannel, useServerStore } from './serverStore';
|
||||||
import { useAuthStore } from './authStore';
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
|
const MAX_MESSAGES_PER_CHANNEL = 200;
|
||||||
|
const MAX_CACHED_CHANNELS = 20;
|
||||||
|
const EVICT_TO_CHANNELS = 15;
|
||||||
|
|
||||||
interface TypingUser {
|
interface TypingUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -27,6 +31,7 @@ interface ChatState {
|
|||||||
readStates: Map<string, string>;
|
readStates: Map<string, string>;
|
||||||
unreadChannels: Set<string>;
|
unreadChannels: Set<string>;
|
||||||
realtimeMessageEvents: RealtimeMessageEvent[];
|
realtimeMessageEvents: RealtimeMessageEvent[];
|
||||||
|
channelAccessTimes: Map<string, number>;
|
||||||
setCurrentChannel: (channelId: string | null) => void;
|
setCurrentChannel: (channelId: string | null) => void;
|
||||||
setReplyTo: (message: MessageWithUser | null) => void;
|
setReplyTo: (message: MessageWithUser | null) => void;
|
||||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||||
@@ -64,14 +69,59 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
readStates: new Map(),
|
readStates: new Map(),
|
||||||
unreadChannels: new Set(),
|
unreadChannels: new Set(),
|
||||||
realtimeMessageEvents: [],
|
realtimeMessageEvents: [],
|
||||||
|
channelAccessTimes: new Map(),
|
||||||
|
|
||||||
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
|
setCurrentChannel: (channelId) => {
|
||||||
|
set((state) => {
|
||||||
|
const newAccessTimes = new Map(state.channelAccessTimes);
|
||||||
|
if (channelId) {
|
||||||
|
newAccessTimes.set(channelId, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict stale channels if we have too many cached
|
||||||
|
let newMessages = state.messages;
|
||||||
|
let newHasMore = state.hasMore;
|
||||||
|
if (state.messages.size > MAX_CACHED_CHANNELS) {
|
||||||
|
const entries = [...newAccessTimes.entries()]
|
||||||
|
.filter(([id]) => id !== channelId)
|
||||||
|
.sort((a, b) => a[1] - b[1]);
|
||||||
|
const toEvict = state.messages.size - EVICT_TO_CHANNELS;
|
||||||
|
const evictIds = new Set(entries.slice(0, toEvict).map(([id]) => id));
|
||||||
|
if (evictIds.size > 0) {
|
||||||
|
newMessages = new Map(state.messages);
|
||||||
|
newHasMore = new Map(state.hasMore);
|
||||||
|
for (const id of evictIds) {
|
||||||
|
newMessages.delete(id);
|
||||||
|
newHasMore.delete(id);
|
||||||
|
newAccessTimes.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentChannelId: channelId,
|
||||||
|
channelAccessTimes: newAccessTimes,
|
||||||
|
messages: newMessages,
|
||||||
|
hasMore: newHasMore,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
setReplyTo: (message) => set({ replyTo: message }),
|
setReplyTo: (message) => set({ replyTo: message }),
|
||||||
|
|
||||||
clearAllMessages: () => set({ messages: new Map(), hasMore: new Map() }),
|
clearAllMessages: () => set({
|
||||||
|
messages: new Map(),
|
||||||
|
hasMore: new Map(),
|
||||||
|
typingUsers: new Map(),
|
||||||
|
readStates: new Map(),
|
||||||
|
unreadChannels: new Set(),
|
||||||
|
realtimeMessageEvents: [],
|
||||||
|
channelAccessTimes: new Map(),
|
||||||
|
currentChannelId: null,
|
||||||
|
replyTo: null,
|
||||||
|
}),
|
||||||
|
|
||||||
loadMessages: async (channelId: string, force?: boolean) => {
|
loadMessages: async (channelId: string, force?: boolean) => {
|
||||||
if (!force && get().messages.has(channelId)) return;
|
if (!force && get().hasMore.has(channelId)) return;
|
||||||
set({ isLoading: true, loadError: null });
|
set({ isLoading: true, loadError: null });
|
||||||
try {
|
try {
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
@@ -84,7 +134,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
newMessages.set(channelId, messages as MessageWithUser[]);
|
newMessages.set(channelId, messages as MessageWithUser[]);
|
||||||
const newHasMore = new Map(state.hasMore);
|
const newHasMore = new Map(state.hasMore);
|
||||||
newHasMore.set(channelId, messages.length >= 50);
|
newHasMore.set(channelId, messages.length >= 50);
|
||||||
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
|
const newAccessTimes = new Map(state.channelAccessTimes);
|
||||||
|
newAccessTimes.set(channelId, Date.now());
|
||||||
|
return { messages: newMessages, hasMore: newHasMore, channelAccessTimes: newAccessTimes, isLoading: false, loadError: null };
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ isLoading: false, loadError: (err as Error).message || 'Failed to load messages' });
|
set({ isLoading: false, loadError: (err as Error).message || 'Failed to load messages' });
|
||||||
@@ -232,7 +284,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||||
return m.content !== message.content;
|
return m.content !== message.content;
|
||||||
});
|
});
|
||||||
newMessages.set(channelId, [...filtered, message]);
|
let updated = [...filtered, message];
|
||||||
|
// Cap per-channel messages to prevent memory growth
|
||||||
|
if (updated.length > MAX_MESSAGES_PER_CHANNEL) {
|
||||||
|
updated = updated.slice(updated.length - MAX_MESSAGES_PER_CHANNEL);
|
||||||
|
}
|
||||||
|
newMessages.set(channelId, updated);
|
||||||
return { messages: newMessages };
|
return { messages: newMessages };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -248,7 +305,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
if (!m.id.startsWith('temp_') || m.userId !== message.userId) return true;
|
||||||
return m.content !== message.content;
|
return m.content !== message.content;
|
||||||
});
|
});
|
||||||
newMessages.set(channelId, [...filtered, message]);
|
let updated = [...filtered, message];
|
||||||
|
// Cap per-channel messages to prevent memory growth
|
||||||
|
if (updated.length > MAX_MESSAGES_PER_CHANNEL) {
|
||||||
|
updated = updated.slice(updated.length - MAX_MESSAGES_PER_CHANNEL);
|
||||||
|
}
|
||||||
|
newMessages.set(channelId, updated);
|
||||||
// Append to realtimeMessageEvents (capped at 50)
|
// Append to realtimeMessageEvents (capped at 50)
|
||||||
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
const newEvents = [...state.realtimeMessageEvents, { channelId, message }];
|
||||||
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
if (newEvents.length > 50) newEvents.splice(0, newEvents.length - 50);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface SocialState {
|
|||||||
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
|
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
|
||||||
updateFriendPresence: (userId: string, status: string) => void;
|
updateFriendPresence: (userId: string, status: string) => void;
|
||||||
removeFriendLocally: (userId: string) => void;
|
removeFriendLocally: (userId: string) => void;
|
||||||
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSocialStore = create<SocialState>((set, get) => ({
|
export const useSocialStore = create<SocialState>((set, get) => ({
|
||||||
@@ -139,4 +140,6 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reset: () => set({ friends: [], requests: [], isLoading: false, error: null }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Room } from 'livekit-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all unique RTCPeerConnections from the LiveKit Room engine.
|
||||||
|
* Different livekit-client versions expose the PC at different internal paths.
|
||||||
|
*/
|
||||||
|
export function discoverPeerConnections(room: Room): RTCPeerConnection[] {
|
||||||
|
const engine = (room as any)?.engine;
|
||||||
|
if (!engine) return [];
|
||||||
|
|
||||||
|
const pcs: RTCPeerConnection[] = [];
|
||||||
|
const seen = new WeakSet<object>();
|
||||||
|
|
||||||
|
const tryAdd = (val: any) => {
|
||||||
|
if (val && typeof val.getStats === 'function' && !seen.has(val)) {
|
||||||
|
seen.add(val);
|
||||||
|
pcs.push(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Current livekit-client (1.x+): engine.pcManager.{publisher,subscriber}.pc
|
||||||
|
tryAdd(engine.pcManager?.publisher?.pc);
|
||||||
|
tryAdd(engine.pcManager?.subscriber?.pc);
|
||||||
|
// Private backing field fallback
|
||||||
|
tryAdd(engine.pcManager?.publisher?._pc);
|
||||||
|
tryAdd(engine.pcManager?.subscriber?._pc);
|
||||||
|
// Older livekit-client paths
|
||||||
|
tryAdd(engine.publisher?.pc);
|
||||||
|
tryAdd(engine.subscriber?.pc);
|
||||||
|
// Unified-plan single PC
|
||||||
|
tryAdd(engine.pc);
|
||||||
|
tryAdd((room as any).pc);
|
||||||
|
|
||||||
|
return pcs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the publisher RTCPeerConnection from a LiveKit Room.
|
||||||
|
* Used by overdrive to inject RTP sender parameters.
|
||||||
|
*/
|
||||||
|
export function getPublisherPC(room: Room): RTCPeerConnection | null {
|
||||||
|
const engine = (room as any)?.engine;
|
||||||
|
if (!engine) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
engine.pcManager?.publisher?.pc ??
|
||||||
|
engine.pcManager?.publisher?._pc ??
|
||||||
|
engine.publisher?.pc ??
|
||||||
|
engine.pc ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extract the underlying MediaStreamTrack from a LiveKit track object.
|
||||||
|
* Handles both public `.mediaStreamTrack` and private `._mediaStreamTrack`.
|
||||||
|
*/
|
||||||
|
export function getMediaStreamTrack(track: unknown): MediaStreamTrack | null {
|
||||||
|
if (!track) return null;
|
||||||
|
const t = track as any;
|
||||||
|
return t.mediaStreamTrack ?? t._mediaStreamTrack ?? null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useVoiceStore } from '../stores/voiceStore';
|
|||||||
import type { ScreenShareConfig } from '../stores/voiceStore';
|
import type { ScreenShareConfig } from '../stores/voiceStore';
|
||||||
import { AudioManager } from '../audio/AudioManager';
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
import { wsSend } from '../hooks/useWebSocket';
|
import { wsSend } from '../hooks/useWebSocket';
|
||||||
|
import { getPublisherPC, getMediaStreamTrack } from './livekitInternals';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -87,12 +88,12 @@ export async function applyOverdrive(
|
|||||||
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
||||||
if (!pub?.track) return;
|
if (!pub?.track) return;
|
||||||
|
|
||||||
const engine = (room as any).engine;
|
const pc = getPublisherPC(room);
|
||||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
|
|
||||||
if (!pc) return;
|
if (!pc) return;
|
||||||
|
|
||||||
const senders = (pc as RTCPeerConnection).getSenders();
|
const pubMediaTrack = getMediaStreamTrack(pub.track);
|
||||||
const sender = senders.find(s => s.track?.id === (pub.track as any).mediaStreamTrack?.id);
|
const senders = pc.getSenders();
|
||||||
|
const sender = senders.find(s => s.track?.id === pubMediaTrack?.id);
|
||||||
if (!sender) return;
|
if (!sender) return;
|
||||||
|
|
||||||
const params = sender.getParameters();
|
const params = sender.getParameters();
|
||||||
|
|||||||
Reference in New Issue
Block a user