feat(messages): stream history one-message-per-WS-frame instead of 50-row batch

- backend: add streamMany generator (paginated, yields one record at a time)
  + messagesService.streamMessages + WS 'stream_messages' handler emitting
  'message_snapshot' per message, 'message_snapshot_end' with nextCursor
- frontend: useMessagesStream hook accumulates snapshots into SWR list,
  SSR getMessages seeds first paint, WsHook gains sendText
- add stream-many.test.ts locking the one-at-a-time + cursor contract
This commit is contained in:
asepharyana
2026-08-18 10:21:08 +07:00
parent 95f2903067
commit 0cb0b82fb1
11 changed files with 392 additions and 3 deletions
@@ -172,6 +172,71 @@ export class MessagesRepository {
return { data, nextCursor };
}
/**
* Async generator that yields messages ONE AT A TIME for WS streaming.
* Each `.next()` runs its own bounded DB query (limit+1) advancing on the
* `created_at` cursor, so memory stays flat and the caller can emit one WS
* frame per message (no 50-row batch). Stops when a page returns < limit.
*/
async *streamMany(
query: MessageQuery,
pageSize = 50,
): AsyncGenerator<ReturnType<typeof mapMessageRow>, void, unknown> {
const conditions: SQL[] = [];
if (query.guildId) {
conditions.push(eq(pgMessagesTable.guild_id, query.guildId));
}
if (query.channelId) {
conditions.push(eq(pgMessagesTable.channel_id, query.channelId));
}
if (query.userId) {
conditions.push(eq(pgMessagesTable.user_id, query.userId));
}
if (query.status) {
conditions.push(eq(pgMessagesTable.ai_status, query.status));
}
if (EXCLUDED_THREAD_IDS.length > 0) {
const excludeThreads = or(
isNull(pgMessagesTable.thread_id),
notInArray(pgMessagesTable.thread_id, EXCLUDED_THREAD_IDS),
);
if (excludeThreads) conditions.push(excludeThreads);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
let cursor: string | undefined = query.cursor;
while (true) {
const pageConditions = where ? [where] : [];
if (cursor) {
pageConditions.push(lt(pgMessagesTable.created_at, Number(cursor)));
}
const pageWhere =
pageConditions.length > 0 ? and(...pageConditions) : undefined;
const db = getDatabase();
const rows = await db
.select()
.from(pgMessagesTable)
.where(pageWhere)
.orderBy(desc(pgMessagesTable.created_at))
.limit(pageSize + 1);
if (rows.length === 0) return;
const hasMore = rows.length > pageSize;
const pageRows = hasMore ? rows.slice(0, pageSize) : rows;
for (const r of pageRows) {
yield mapMessageRow(r as Record<string, unknown>);
}
if (!hasMore) return;
cursor = String(rows[pageSize - 1].created_at);
}
}
async create(data: MessageCreate) {
const db = getDatabase();
const id = crypto.randomUUID();
@@ -15,6 +15,14 @@ export class MessagesService {
return messagesRepository.findMany(query);
}
/**
* Stream messages one at a time (no 50-row batch). The WS handler iterates
* this generator and emits one `message_snapshot` frame per message.
*/
streamMessages(query: MessageQuery, pageSize = 50) {
return messagesRepository.streamMany(query, pageSize);
}
async getMessagesByChannel(channelId: string, query: MessageQuery) {
if (!channelId) {
throw new ValidationError("channelId is required");
+68
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocket, WebSocketServer } from "ws";
import { messagesService } from "../modules/messages/messages.service.js";
import { config } from "../shared/config/index.js";
import { BACKEND_COMMAND, BACKEND_VOICE_TRANSMIT } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
@@ -140,6 +141,73 @@ export function createWebSocketServer(server: Server): WebSocketServer {
);
});
// Stream historical messages one-by-one over WS (no 50-row batch).
// The frontend requests it once per channel switch; the backend emits one
// `message_snapshot` frame per message so the UI renders progressively.
jsonHandlers.set("stream_messages", async (ws, message) => {
if (ws.readyState !== WebSocket.OPEN) return;
const payload = (message.payload ?? {}) as {
guildId?: string;
channelId?: string;
cursor?: string;
limit?: number;
};
const guildId = payload.guildId;
const channelId = payload.channelId;
if (!guildId && !channelId) {
logger.warn({ payload }, "stream_messages requires guildId or channelId");
return;
}
const pageSize = 50; // internal DB page size; still emitted one frame at a time
const maxFrames = Math.min(payload.limit ?? 200, 500);
let sent = 0;
let nextCursor: string | null = null;
try {
for await (const msg of messagesService.streamMessages(
{
guildId,
channelId,
cursor: payload.cursor,
} as never,
pageSize,
)) {
if (ws.readyState !== WebSocket.OPEN) break;
// Streamed DESC (newest first); the oldest emitted carries the smallest
// created_at, which is exactly the next-page cursor for "load older".
const createdAt = (msg as { created_at?: number }).created_at;
if (createdAt !== undefined) nextCursor = String(createdAt);
ws.send(
JSON.stringify({
type: "message_snapshot",
data: msg,
}),
);
sent++;
if (sent >= maxFrames) break;
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor },
}),
);
}
} catch (err) {
logger.error({ err }, "stream_messages failed");
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "message_snapshot_end",
data: { sent, nextCursor, error: true },
}),
);
}
}
});
wss.on("connection", (ws: WebSocket, req) => {
// Parse auth token from query string
const rawUrl = req.url ?? "/";