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
@@ -1,5 +1,5 @@
import { PageTransition } from "@/components/shared";
import { getConfig, getGuilds } from "@/lib/api/server";
import { getConfig, getGuilds, getMessages } from "@/lib/api/server";
import { MessagesView } from "./view";
export const dynamic = "force-dynamic";
@@ -7,8 +7,16 @@ export const dynamic = "force-dynamic";
export default async function MessagesPage() {
let config: import("@/lib/types/guild").AppConfig | undefined;
let guilds: import("@/lib/types").Guild[] | undefined;
let initialMessages: {
data: import("@/lib/types").MessageRecord[];
nextCursor: string | null;
} | null = null;
try {
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
const gid = config?.monitorGuildId;
if (gid) {
initialMessages = await getMessages(gid, undefined, 50);
}
} catch {
/* client hooks surface errors */
}
@@ -17,6 +25,7 @@ export default async function MessagesPage() {
<MessagesView
initialGuilds={guilds}
initialGuildId={config?.monitorGuildId ?? null}
initialMessages={initialMessages}
/>
</PageTransition>
);
@@ -32,6 +32,7 @@ import {
useMessageSearch,
useMessages,
useMessagesHasMore,
useMessagesStream,
useMessagesWsSync,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
@@ -49,9 +50,14 @@ import { useWebSocket } from "@/lib/ws/context";
export function MessagesView({
initialGuilds,
initialGuildId,
initialMessages,
}: {
initialGuilds?: Guild[];
initialGuildId?: string | null;
initialMessages?: {
data: MessageRecord[];
nextCursor: string | null;
} | null;
}) {
const ws = useWebSocket();
const [guildId, setGuildId] = useState<string | null>(
@@ -69,7 +75,15 @@ export function MessagesView({
data: messages,
isLoading,
error,
} = useMessages(guildId ?? "", channelId ?? undefined);
} = useMessages(
guildId ?? "",
channelId ?? undefined,
initialMessages ?? undefined,
);
// Stream history one message per WS frame (replaces the 50-row batched fetch).
// Drives snapshots into the SWR list above as they arrive; falls back to the
// SSR `initialMessages` seed if WS is unavailable.
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
// Cursor to the next (older) page + whether more history exists.
const { data: pageInfo } = useMessagesHasMore(
guildId ?? "",