@noverachat/sdk-web 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,504 @@
1
+ /**
2
+ * 코어 소유 메시지 상태 — noverachat_dart `MessageCollection`의 TS 미러.
3
+ *
4
+ * RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
5
+ * 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
6
+ * 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
7
+ * 코어가 강제한다. 추후 diff 병합은 `applyFresh`의 mergeDiff 분기
8
+ * 하나로 들어온다.
9
+ */
10
+
11
+ import type {
12
+ DeleteScope,
13
+ MessageIdStr,
14
+ MessageOut,
15
+ ReactionEntry,
16
+ SendFileOptions,
17
+ WsChatReceive,
18
+ WsMessageDeleted,
19
+ WsMessagesCleared,
20
+ WsMessageUpdated,
21
+ WsReactionAdded,
22
+ WsReactionRemoved,
23
+ WsSyncTick,
24
+ } from "../types.js";
25
+ import type { CachePolicy } from "../cache.js";
26
+ import type { Room } from "./room.js";
27
+ import {
28
+ chatMessageFromHistory,
29
+ chatMessageFromLive,
30
+ pendingChatMessage,
31
+ pendingFileChatMessage,
32
+ type ChatMessage,
33
+ } from "../chat-message.js";
34
+ import { EventBus } from "../internal/event-bus.js";
35
+ import { idGreater } from "../internal/compare-id.js";
36
+
37
+ /**
38
+ * 컬렉션 상태가 바뀐 이유. 지금 소비자(얇은 래퍼)는 "바뀌었다"만 알면
39
+ * 되므로 kind만 담는다 — 세밀한 렌더 최적화가 필요해지면 필드를
40
+ * **추가**(비파괴)로 확장한다.
41
+ *
42
+ * - `hydrated` 캐시 스냅샷이 렌더됨 (콜드 스타트 힌트 — 곧 `replaced`가 따라온다)
43
+ * - `replaced` API 응답이 스냅샷/기존 페이지를 통째로 대체함 (서버가 이겼다)
44
+ * - `inserted` 메시지가 추가됨 (라이브 수신·낙관적 버블·스크롤백 페이지)
45
+ * - `updated` 기존 메시지가 제자리 갱신됨 (수정·삭제 플래그·리액션·ack 플립)
46
+ * - `watermarks` 읽음 워터마크가 전진함
47
+ */
48
+ export type CollectionChangeKind =
49
+ | "hydrated"
50
+ | "replaced"
51
+ | "inserted"
52
+ | "updated"
53
+ | "watermarks";
54
+
55
+ /** `MessageCollection`의 `change` 이벤트로 흐르는 변경 통지. */
56
+ export interface CollectionChange {
57
+ kind: CollectionChangeKind;
58
+ }
59
+
60
+ interface CollectionEvents {
61
+ change: CollectionChange;
62
+ }
63
+
64
+ let uploadSeq = 0;
65
+
66
+ /**
67
+ * A `Room`'s message list as owned, live state — the core-side equivalent
68
+ * of Sendbird's `MessageCollection` (without a local DB).
69
+ *
70
+ * Applies the room's events into one `messages` list, drives optimistic
71
+ * sends, and runs the snapshot-cache contract (`load()` hydrates from cache
72
+ * instantly, then wholesale-replaces when the API answers). Subscribe with
73
+ * `on("change", ...)` and re-render `messages` on every event — all state
74
+ * mutations and change emits are synchronous with the WS dispatch.
75
+ *
76
+ * ```ts
77
+ * const col = chat.room("room_123").collection();
78
+ * const unsub = col.on("change", () => render(col.messages));
79
+ * await col.load();
80
+ * col.send("hello");
81
+ * col.markRead(col.messages.at(-1)!.id);
82
+ * // ...
83
+ * col.dispose(); unsub();
84
+ * ```
85
+ */
86
+ export class MessageCollection {
87
+ private readonly bus = new EventBus<CollectionEvents>();
88
+ private readonly unsubs: Array<() => void> = [];
89
+
90
+ private _messages: ChatMessage[] = [];
91
+ private _readWatermarks: Record<string, string> = {};
92
+ private _hasMore = false;
93
+ private oldestCursor: string | null = null;
94
+ private loadingMore = false;
95
+
96
+ /**
97
+ * 캐시 스냅샷으로 렌더된 머리쪽 메시지 수 — API 도착 시 이만큼 걷어내고
98
+ * 통째 교체한다(replaceByApi). 라이브 수신은 리스트 꼬리에 붙으므로
99
+ * 머리 range 제거로 스냅샷만 정확히 걷힌다.
100
+ */
101
+ private hydratedCount = 0;
102
+
103
+ constructor(
104
+ /** The room this collection is bound to. */
105
+ public readonly room: Room,
106
+ private readonly policy: CachePolicy = "replaceByApi",
107
+ ) {
108
+ this.unsubs.push(
109
+ room.on("message", (f) => this.onIncoming(f)),
110
+ room.on("messageUpdated", (f) => this.onUpdated(f)),
111
+ room.on("messageDeleted", (f) => this.onDeleted(f)),
112
+ room.on("messagesCleared", (f) => this.onCleared(f)),
113
+ room.on("reactionAdded", (f) => this.onReactionAdded(f)),
114
+ room.on("reactionRemoved", (f) => this.onReactionRemoved(f)),
115
+ room.on("syncTick", (f) => this.onSyncTick(f)),
116
+ );
117
+ }
118
+
119
+ /** The current messages, oldest first. Treat as read-only. */
120
+ get messages(): readonly ChatMessage[] {
121
+ return this._messages;
122
+ }
123
+
124
+ /** True when older history remains — fire `loadMore()` from the top of
125
+ * the scroll view. */
126
+ get hasMore(): boolean {
127
+ return this._hasMore;
128
+ }
129
+
130
+ /** Per-user read watermarks (`userId` → last read `messageId`), kept live
131
+ * from `sync_tick` frames. Treat as read-only. */
132
+ get readWatermarks(): Readonly<Record<string, string>> {
133
+ return this._readWatermarks;
134
+ }
135
+
136
+ /** State-change notifications. One event per mutation; re-read `messages`
137
+ * (and friends) when it fires. Returns an unsubscribe fn. */
138
+ on(event: "change", fn: (c: CollectionChange) => void): () => void {
139
+ return this.bus.on(event, fn);
140
+ }
141
+
142
+ /** Whether `userId` has read `messageId` according to the latest watermark. */
143
+ isReadBy(userId: string, messageId: string): boolean {
144
+ const w = this._readWatermarks[userId];
145
+ if (!w) return false;
146
+ return !idGreater(messageId, w);
147
+ }
148
+
149
+ /** How many of the given users have read `messageId`. Pass the room's
150
+ * member ids (minus the sender) to get a KakaoTalk-style unread count:
151
+ * `members.length - readCount(...)`. */
152
+ readCount(messageId: string, userIds: Iterable<string>): number {
153
+ let n = 0;
154
+ for (const u of userIds) if (this.isReadBy(u, messageId)) n++;
155
+ return n;
156
+ }
157
+
158
+ // ── loading — cache hydrate → API wholesale replace ─────────────
159
+
160
+ /**
161
+ * Load the most recent page of history.
162
+ *
163
+ * `ClientOptions.cacheStore`가 주입돼 있으면 캐시 스냅샷을 먼저 렌더한
164
+ * 뒤(`hydrated`), API 응답 도착 시 스냅샷을 걷어내고 통째로 교체한다
165
+ * (`replaced`). 스냅샷 렌더 중에는 `hasMore`/스크롤백을 열지 않는다 —
166
+ * 커서는 API 응답의 것만 신뢰한다.
167
+ */
168
+ async load(limit = 50): Promise<void> {
169
+ const pending = this.room.listRecent(limit); // API를 먼저 띄우고
170
+ await this.hydrateFromCache(); // 기다리는 동안 캐시를 그린다
171
+ const page = await pending;
172
+ this.applyFresh(page);
173
+ }
174
+
175
+ /** Scrollback: load the page of messages older than what's on screen and
176
+ * prepend it. No-op while a previous call is in flight or when `hasMore`
177
+ * is false. Returns the number of messages added. */
178
+ async loadMore(limit = 50): Promise<number> {
179
+ const cursor = this.oldestCursor;
180
+ if (this.loadingMore || !this._hasMore || cursor == null) return 0;
181
+ this.loadingMore = true;
182
+ try {
183
+ const page = await this.room.listBefore(cursor, limit);
184
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
185
+ this.oldestCursor = page.nextCursor;
186
+ this._hasMore = page.hasMore;
187
+ this.emit("inserted");
188
+ return page.items.length;
189
+ } finally {
190
+ this.loadingMore = false;
191
+ }
192
+ }
193
+
194
+ /** 캐시 스냅샷 즉시 렌더 — 빈 리스트일 때만. 실패는 조용히 무시(캐시는
195
+ * 힌트일 뿐). */
196
+ private async hydrateFromCache(): Promise<void> {
197
+ if (this._messages.length > 0 || this.hydratedCount > 0) return;
198
+ const cached = await this.room.cachedRecent();
199
+ if (!cached || cached.items.length === 0 || this._messages.length > 0) {
200
+ return;
201
+ }
202
+ this._messages.unshift(...cached.items.map(chatMessageFromHistory));
203
+ this.hydratedCount = cached.items.length;
204
+ this.emit("hydrated");
205
+ }
206
+
207
+ /**
208
+ * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
209
+ * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
210
+ * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
211
+ */
212
+ private applyFresh(page: {
213
+ items: MessageOut[];
214
+ nextCursor: MessageIdStr | null;
215
+ hasMore: boolean;
216
+ }): void {
217
+ if (this.policy === "mergeDiff") {
218
+ throw new Error('CachePolicy "mergeDiff" 는 아직 미구현 — 서버 무효화 규칙 확정 후 지원 예정');
219
+ }
220
+ if (this.hydratedCount > 0) {
221
+ this._messages.splice(0, this.hydratedCount); // 스냅샷 통째 폐기 → 서버가 이긴다
222
+ this.hydratedCount = 0;
223
+ }
224
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
225
+ this.oldestCursor = page.nextCursor;
226
+ this._hasMore = page.hasMore;
227
+ this.emit("replaced");
228
+ }
229
+
230
+ // ── optimistic sends ────────────────────────────────────────────
231
+
232
+ /**
233
+ * Send `text` optimistically: a `sending` bubble appears immediately (one
234
+ * microtask later — `Room.send` is async), then flips to `sent` (with the
235
+ * real id) on ack, or `failed` on error. Fire-and-forget: a WS-not-open
236
+ * failure surfaces as a `failed` bubble rather than a rejected promise.
237
+ */
238
+ send(
239
+ text: string,
240
+ opts?: {
241
+ replyToId?: string;
242
+ customType?: string;
243
+ customMeta?: Record<string, unknown>;
244
+ },
245
+ ): void {
246
+ void (async () => {
247
+ let tempId: string;
248
+ let ackPromise: Promise<string>;
249
+ try {
250
+ const sent = await this.room.send({
251
+ content: text,
252
+ ...(opts?.replyToId ? { replyToId: opts.replyToId } : {}),
253
+ ...(opts?.customType ? { customType: opts.customType } : {}),
254
+ ...(opts?.customMeta ? { customMeta: opts.customMeta } : {}),
255
+ });
256
+ tempId = sent.tempId;
257
+ ackPromise = sent.messageId;
258
+ } catch {
259
+ // WS not open — surface a failed bubble rather than throwing into
260
+ // the void (send() is fire-and-forget).
261
+ const localId = `failed_${Date.now()}_${uploadSeq++}`;
262
+ this._messages.push({
263
+ ...pendingChatMessage({ tempId: localId, content: text, ...opts }),
264
+ status: "failed",
265
+ });
266
+ this.emit("inserted");
267
+ return;
268
+ }
269
+ this._messages.push(pendingChatMessage({ tempId, content: text, ...opts }));
270
+ this.emit("inserted");
271
+ this.bindAck(tempId, ackPromise);
272
+ })();
273
+ }
274
+
275
+ /** Send a file optimistically. A FILE bubble with `uploadProgress`
276
+ * appears immediately; progress advances during the S3 upload, then the
277
+ * bubble flips to `sent` on ack (or `failed`). Never throws — failures
278
+ * surface as the bubble's status. */
279
+ async sendFile(file: File, opts?: SendFileOptions): Promise<void> {
280
+ // Local temp id for the optimistic bubble — the real WS temp id only
281
+ // exists after the upload commits (sendFile resolves then).
282
+ const localId = `upload_${Date.now()}_${uploadSeq++}`;
283
+ this._messages.push(
284
+ pendingFileChatMessage({
285
+ tempId: localId,
286
+ name: opts?.name ?? file.name,
287
+ mime: file.type,
288
+ size: file.size,
289
+ }),
290
+ );
291
+ this.emit("inserted");
292
+
293
+ try {
294
+ const sent = await this.room.sendFile(file, {
295
+ ...(opts ?? {}),
296
+ onProgress: (p) => {
297
+ this.mutateByTempId(localId, (m) => ({
298
+ ...m,
299
+ uploadProgress: p.ratio,
300
+ }));
301
+ opts?.onProgress?.(p);
302
+ },
303
+ });
304
+ // Upload committed — bind the bubble to the real file id and ack.
305
+ this.mutateByTempId(localId, (m) => ({
306
+ ...m,
307
+ fileId: sent.fileId,
308
+ file: m.file ? { ...m.file, id: sent.fileId } : m.file,
309
+ uploadProgress: null,
310
+ }));
311
+ const realId = await sent.messageId;
312
+ this.mutateByTempId(localId, (m) => ({
313
+ ...m,
314
+ id: realId,
315
+ status: "sent",
316
+ }));
317
+ } catch {
318
+ this.mutateByTempId(localId, (m) => ({
319
+ ...m,
320
+ status: "failed",
321
+ uploadProgress: null,
322
+ }));
323
+ }
324
+ }
325
+
326
+ /** Edit a message's content (server-side; peers get `message_updated`).
327
+ * The local copy updates optimistically. */
328
+ async edit(messageId: string, content: string): Promise<void> {
329
+ await this.room.edit(messageId, { content });
330
+ this.mutateById(messageId, (m) => ({
331
+ ...m,
332
+ content,
333
+ editedCount: m.editedCount + 1,
334
+ }));
335
+ }
336
+
337
+ /** Delete a message. `scope: "ALL"` (default) removes it for everyone;
338
+ * `"MY"` hides it only for the caller. Local copy flips to deleted. */
339
+ async delete(messageId: string, scope: DeleteScope = "ALL"): Promise<void> {
340
+ await this.room.delete(messageId, scope);
341
+ this.mutateById(messageId, (m) => ({ ...m, isDeleted: true }));
342
+ }
343
+
344
+ /** Add/remove the caller's reaction. Pass `myUserId` so the toggle can
345
+ * tell whether the caller already reacted with `key`. The authoritative
346
+ * state arrives back via the reaction WS events. */
347
+ async toggleReaction(
348
+ messageId: string,
349
+ key: string,
350
+ myUserId: string,
351
+ ): Promise<void> {
352
+ const m = this._messages.find((x) => x.id === messageId);
353
+ const mine =
354
+ m?.reactions.some(
355
+ (r) => r.key === key && r.userIds.includes(myUserId),
356
+ ) ?? false;
357
+ if (mine) {
358
+ await this.room.unreact(messageId, key);
359
+ } else {
360
+ await this.room.react(messageId, key);
361
+ }
362
+ }
363
+
364
+ /** Mark `messageId` read (debounced inside the SDK). */
365
+ markRead(messageId: string): void {
366
+ this.room.markRead(messageId);
367
+ }
368
+
369
+ /** Force-send the pending read watermark now (e.g. on unmount / tab
370
+ * backgrounding). */
371
+ flushMarkRead(): void {
372
+ this.room.flushMarkRead();
373
+ }
374
+
375
+ /** Unsubscribe from the room and flush the pending read watermark. The
376
+ * collection must not be used afterwards. */
377
+ dispose(): void {
378
+ this.room.flushMarkRead();
379
+ for (const u of this.unsubs.splice(0)) u();
380
+ this.bus.removeAll();
381
+ }
382
+
383
+ // ── event handlers ──────────────────────────────────────────────
384
+
385
+ private onIncoming(f: WsChatReceive): void {
386
+ this._messages.push(chatMessageFromLive(f));
387
+ this.emit("inserted");
388
+ }
389
+
390
+ private onUpdated(f: WsMessageUpdated): void {
391
+ this.mutateById(f.message_id, (m) => ({
392
+ ...m,
393
+ content: f.content ?? m.content,
394
+ editedCount: m.editedCount + 1,
395
+ }));
396
+ }
397
+
398
+ private onDeleted(f: WsMessageDeleted): void {
399
+ this.mutateById(f.message_id, (m) => ({ ...m, isDeleted: true }));
400
+ }
401
+
402
+ private onCleared(f: WsMessagesCleared): void {
403
+ // Operator wipe up to (and including) up_to_message_id — flip local
404
+ // copies. A null cutoff means the whole room was wiped.
405
+ const upTo = f.up_to_message_id;
406
+ let changed = false;
407
+ this._messages = this._messages.map((m) => {
408
+ if (!m.isDeleted && (upTo == null || !idGreater(m.id, upTo))) {
409
+ changed = true;
410
+ return { ...m, isDeleted: true };
411
+ }
412
+ return m;
413
+ });
414
+ if (changed) this.emit("updated");
415
+ }
416
+
417
+ private onReactionAdded(f: WsReactionAdded): void {
418
+ this.mutateById(f.message_id, (m) => {
419
+ const next: ReactionEntry[] = m.reactions.map((r) =>
420
+ r.key === f.reaction_key
421
+ ? {
422
+ key: r.key,
423
+ userIds: [...new Set([...r.userIds, f.user_id])],
424
+ updatedAt: f.updated_at,
425
+ }
426
+ : r,
427
+ );
428
+ if (!next.some((r) => r.key === f.reaction_key)) {
429
+ next.push({
430
+ key: f.reaction_key,
431
+ userIds: [f.user_id],
432
+ updatedAt: f.updated_at,
433
+ });
434
+ }
435
+ return { ...m, reactions: next };
436
+ });
437
+ }
438
+
439
+ private onReactionRemoved(f: WsReactionRemoved): void {
440
+ this.mutateById(f.message_id, (m) => {
441
+ const next: ReactionEntry[] = [];
442
+ for (const r of m.reactions) {
443
+ if (r.key !== f.reaction_key) {
444
+ next.push(r);
445
+ continue;
446
+ }
447
+ const users = r.userIds.filter((u) => u !== f.user_id);
448
+ if (users.length > 0) {
449
+ next.push({ key: r.key, userIds: users, updatedAt: f.updated_at });
450
+ }
451
+ }
452
+ return { ...m, reactions: next };
453
+ });
454
+ }
455
+
456
+ private onSyncTick(f: WsSyncTick): void {
457
+ let changed = false;
458
+ for (const [userId, mid] of Object.entries(f.read_states)) {
459
+ const cur = this._readWatermarks[userId];
460
+ if (!cur || idGreater(mid, cur)) {
461
+ this._readWatermarks[userId] = mid;
462
+ changed = true;
463
+ }
464
+ }
465
+ if (changed) this.emit("watermarks");
466
+ }
467
+
468
+ // ── helpers ─────────────────────────────────────────────────────
469
+
470
+ private bindAck(tempId: string, messageId: Promise<string>): void {
471
+ messageId
472
+ .then((realId) => {
473
+ this.mutateByTempId(tempId, (m) => ({
474
+ ...m,
475
+ id: realId,
476
+ status: "sent",
477
+ }));
478
+ })
479
+ .catch(() => {
480
+ this.mutateByTempId(tempId, (m) => ({ ...m, status: "failed" }));
481
+ });
482
+ }
483
+
484
+ private mutateById(id: string, f: (m: ChatMessage) => ChatMessage): void {
485
+ const i = this._messages.findIndex((m) => m.id === id);
486
+ if (i === -1) return;
487
+ this._messages[i] = f(this._messages[i]!);
488
+ this.emit("updated");
489
+ }
490
+
491
+ private mutateByTempId(
492
+ tempId: string,
493
+ f: (m: ChatMessage) => ChatMessage,
494
+ ): void {
495
+ const i = this._messages.findIndex((m) => m.tempId === tempId);
496
+ if (i === -1) return;
497
+ this._messages[i] = f(this._messages[i]!);
498
+ this.emit("updated");
499
+ }
500
+
501
+ private emit(kind: CollectionChangeKind): void {
502
+ this.bus.emit("change", { kind });
503
+ }
504
+ }
@@ -12,6 +12,7 @@ import type { HttpClient } from "../transport/http.js";
12
12
  import type { ReconnectingWs } from "../transport/ws.js";
13
13
  import type {
14
14
  Announcement,
15
+ ClientOptions,
15
16
  DeleteScope,
16
17
  FileKind,
17
18
  FileMeta,
@@ -20,6 +21,7 @@ import type {
20
21
  MessageIdStr,
21
22
  MessageOut,
22
23
  RoomMemberOut,
24
+ RoomOut,
23
25
  RoomUnread,
24
26
  SendFileOptions,
25
27
  SendParams,
@@ -33,8 +35,10 @@ import type {
33
35
  WsSyncTick,
34
36
  WsTypingBroadcast,
35
37
  } from "../types.js";
38
+ import { CacheKeys, type CachePolicy, type CacheStore } from "../cache.js";
36
39
  import { EventBus } from "../internal/event-bus.js";
37
40
  import { tempId as newTempId } from "../internal/temp-id.js";
41
+ import { MessageCollection } from "./message-collection.js";
38
42
  import {
39
43
  parseAnnouncementOut,
40
44
  parseBulkDeleteBySenderResult,
@@ -46,6 +50,7 @@ import {
46
50
  parseJoinRequestListResponse,
47
51
  parseMessageOut,
48
52
  parseRoomMemberOut,
53
+ parseRoomOut,
49
54
  parseRoomUnread,
50
55
  } from "../generated/rest.js";
51
56
  import type {
@@ -89,7 +94,7 @@ function toFileInline(f: {
89
94
  // Enhance the GENERATED `parseMessageOut` with the two client-side
90
95
  // extras it can't derive from the OpenAPI schema: parse `file`/`files`
91
96
  // into the camelCase `FileInline` shape, and lift sender-attached
92
- // `_customMeta` out of `data` (Phase D #11). Everything else — key
97
+ // `_customMeta` out of `data`. Everything else — key
93
98
  // renames, nested sender/reactions — comes straight from the codegen.
94
99
  function toMessageOut(j: unknown): MessageOut {
95
100
  const base = parseMessageOut(j);
@@ -196,13 +201,47 @@ export class Room {
196
201
  */
197
202
  private mySentTempIds = new Set<string>();
198
203
 
204
+ // ── 스냅샷 캐시 협력자 (클라이언트 옵션에서 흘러듦) ──────────────
205
+ private readonly cacheStore: CacheStore | null;
206
+ private readonly cachePolicy: CachePolicy;
207
+ private readonly logger: ClientOptions["logger"];
208
+ /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
209
+ private timerSeconds: number | null = null;
210
+ private timerKnown = false;
211
+
199
212
  constructor(
200
213
  public readonly roomId: string,
201
214
  private http: HttpClient,
202
215
  private ws: ReconnectingWs,
203
216
  readDebounceMs: number,
217
+ opts?: {
218
+ cacheStore?: CacheStore;
219
+ cachePolicy?: CachePolicy;
220
+ logger?: ClientOptions["logger"];
221
+ },
204
222
  ) {
205
223
  this.readDebounceMs = readDebounceMs;
224
+ this.cacheStore = opts?.cacheStore ?? null;
225
+ this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
226
+ this.logger = opts?.logger;
227
+ }
228
+
229
+ /**
230
+ * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
231
+ *
232
+ * 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
233
+ * 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
234
+ * `dispose()`로 정리한다.
235
+ *
236
+ * ```ts
237
+ * const col = chat.room("room_123").collection();
238
+ * const unsub = col.on("change", () => render(col.messages));
239
+ * await col.load();
240
+ * col.send("hello");
241
+ * ```
242
+ */
243
+ collection(policy?: CachePolicy): MessageCollection {
244
+ return new MessageCollection(this, policy ?? this.cachePolicy);
206
245
  }
207
246
 
208
247
  private _sendPendingMarkRead(): void {
@@ -260,7 +299,7 @@ export class Room {
260
299
  forward_blocked: Boolean(p.forwardBlocked),
261
300
  }
262
301
  : (p.data ?? null);
263
- // Phase D #11 — merge client-supplied custom metadata under a reserved
302
+ // Merge client-supplied custom metadata under a reserved
264
303
  // `_customMeta` key in `data`. Backend passes the whole `data` blob
265
304
  // through untouched, so receiving clients read it back via
266
305
  // `MessageOut.customMeta` (see `toMessageOut`).
@@ -792,11 +831,85 @@ export class Room {
792
831
  const raw = await this.http.request<{
793
832
  items: unknown[]; next_cursor: string | null; has_more: boolean;
794
833
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
795
- return {
796
- items: raw.items.map(toMessageOut),
834
+ const page = {
835
+ items: raw.items.map(toMessageOut), // 파싱 성공한 응답만 캐시에 넣는다
797
836
  nextCursor: raw.next_cursor,
798
837
  hasMore: raw.has_more,
799
838
  };
839
+ void this.cacheWriteRecent(raw);
840
+ return page;
841
+ }
842
+
843
+ /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
844
+ async detail(): Promise<RoomOut> {
845
+ const raw = await this.http.request<unknown>(
846
+ "GET", `/api/v1/rooms/${this.roomId}`,
847
+ );
848
+ const out = parseRoomOut(raw);
849
+ this.timerSeconds = out.timerSeconds;
850
+ this.timerKnown = true;
851
+ return out;
852
+ }
853
+
854
+ /**
855
+ * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
856
+ * 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
857
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
858
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
859
+ *
860
+ * 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
861
+ * 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
862
+ */
863
+ async cachedRecent(): Promise<{
864
+ items: MessageOut[];
865
+ nextCursor: MessageIdStr | null;
866
+ hasMore: boolean;
867
+ } | null> {
868
+ const store = this.cacheStore;
869
+ if (!store) return null;
870
+ const key = CacheKeys.msgs(this.roomId);
871
+ try {
872
+ const raw = await store.read(key);
873
+ if (raw == null) return null;
874
+ const j = JSON.parse(raw) as {
875
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
876
+ };
877
+ return {
878
+ items: j.items.map(toMessageOut),
879
+ nextCursor: j.next_cursor,
880
+ hasMore: j.has_more,
881
+ };
882
+ } catch (err) {
883
+ this.logger?.("warn", `cache_read_failed key=${key}`, err);
884
+ try {
885
+ await store.remove(key);
886
+ } catch {
887
+ // 폐기 실패도 무시 — 캐시는 힌트일 뿐
888
+ }
889
+ return null;
890
+ }
891
+ }
892
+
893
+ /**
894
+ * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
895
+ * 스냅샷도 지운다 — 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
896
+ * 살아남는 보안 충돌을 원천 차단한다.
897
+ */
898
+ private async cacheWriteRecent(raw: unknown): Promise<void> {
899
+ const store = this.cacheStore;
900
+ if (!store) return;
901
+ const key = CacheKeys.msgs(this.roomId);
902
+ try {
903
+ if (!this.timerKnown) await this.detail(); // 세션당 1회 — 이후는 메모 재사용
904
+ if (this.timerSeconds != null) {
905
+ await store.remove(key);
906
+ return;
907
+ }
908
+ await store.write(key, JSON.stringify(raw));
909
+ } catch (err) {
910
+ // 타이머 확인 실패 포함 — 확신 없으면 저장하지 않는 쪽으로 기운다.
911
+ this.logger?.("warn", `cache_write_failed key=${key}`, err);
912
+ }
800
913
  }
801
914
 
802
915
  /**
@@ -822,7 +935,7 @@ export class Room {
822
935
  };
823
936
  }
824
937
 
825
- /** Phase D #2 — 방 내 메시지 검색.
938
+ /** 방 내 메시지 검색.
826
939
  *
827
940
  * ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
828
941
  * 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``