@noverachat/sdk-web 0.3.0 → 0.5.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.
@@ -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,43 @@ 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"];
199
208
  constructor(
200
209
  public readonly roomId: string,
201
210
  private http: HttpClient,
202
211
  private ws: ReconnectingWs,
203
212
  readDebounceMs: number,
213
+ opts?: {
214
+ cacheStore?: CacheStore;
215
+ cachePolicy?: CachePolicy;
216
+ logger?: ClientOptions["logger"];
217
+ },
204
218
  ) {
205
219
  this.readDebounceMs = readDebounceMs;
220
+ this.cacheStore = opts?.cacheStore ?? null;
221
+ this.cachePolicy = opts?.cachePolicy ?? "replaceByApi";
222
+ this.logger = opts?.logger;
223
+ }
224
+
225
+ /**
226
+ * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
227
+ *
228
+ * 컬렉션이 이벤트 반영·낙관적 전송·캐시 hydrate→교체를 전부 담당하므로,
229
+ * 리스트 화면은 컬렉션 하나만 물면 된다. 여러 개 만들 수 있고 각자
230
+ * `dispose()`로 정리한다.
231
+ *
232
+ * ```ts
233
+ * const col = chat.room("room_123").collection();
234
+ * const unsub = col.on("change", () => render(col.messages));
235
+ * await col.load();
236
+ * col.send("hello");
237
+ * ```
238
+ */
239
+ collection(policy?: CachePolicy): MessageCollection {
240
+ return new MessageCollection(this, policy ?? this.cachePolicy);
206
241
  }
207
242
 
208
243
  private _sendPendingMarkRead(): void {
@@ -260,7 +295,7 @@ export class Room {
260
295
  forward_blocked: Boolean(p.forwardBlocked),
261
296
  }
262
297
  : (p.data ?? null);
263
- // Phase D #11 — merge client-supplied custom metadata under a reserved
298
+ // Merge client-supplied custom metadata under a reserved
264
299
  // `_customMeta` key in `data`. Backend passes the whole `data` blob
265
300
  // through untouched, so receiving clients read it back via
266
301
  // `MessageOut.customMeta` (see `toMessageOut`).
@@ -788,15 +823,87 @@ export class Room {
788
823
  items: MessageOut[];
789
824
  nextCursor: MessageIdStr | null;
790
825
  hasMore: boolean;
826
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
827
+ historyFrom: number | null;
791
828
  }> {
792
829
  const raw = await this.http.request<{
793
830
  items: unknown[]; next_cursor: string | null; has_more: boolean;
831
+ history_from?: number | null;
794
832
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
795
- return {
796
- items: raw.items.map(toMessageOut),
833
+ const page = {
834
+ items: raw.items.map(toMessageOut), // 파싱 성공한 응답만 캐시에 넣는다
797
835
  nextCursor: raw.next_cursor,
798
836
  hasMore: raw.has_more,
837
+ historyFrom: raw.history_from ?? null,
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
+ return parseRoomOut(raw);
849
+ }
850
+
851
+ /**
852
+ * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
853
+ * 원문. 콜드 스타트 렌더 힌트 용도다: 있으면 즉시 그리고, `listRecent()`가
854
+ * 도착하면 통째로 교체할 것. 캐시가 없거나(`cacheStore` 미주입 포함)
855
+ * 파싱이 실패하면 `null`을 돌려주고, 깨진 스냅샷은 지운다.
856
+ *
857
+ * 스냅샷의 `nextCursor`/`hasMore`는 시점이 지난 값이므로 스크롤백
858
+ * 커서로 쓰지 말 것 — API 응답이 도착한 뒤의 값만 신뢰한다.
859
+ */
860
+ async cachedRecent(): Promise<{
861
+ items: MessageOut[];
862
+ nextCursor: MessageIdStr | null;
863
+ hasMore: boolean;
864
+ historyFrom: number | null;
865
+ } | null> {
866
+ const store = this.cacheStore;
867
+ if (!store) return null;
868
+ const key = CacheKeys.msgs(this.roomId);
869
+ try {
870
+ const raw = await store.read(key);
871
+ if (raw == null) return null;
872
+ const j = JSON.parse(raw) as {
873
+ items: unknown[]; next_cursor: string | null; has_more: boolean;
874
+ history_from?: number | null;
875
+ };
876
+ return {
877
+ items: j.items.map(toMessageOut),
878
+ nextCursor: j.next_cursor,
879
+ hasMore: j.has_more,
880
+ historyFrom: j.history_from ?? null,
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
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
896
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
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
+ await store.write(key, JSON.stringify(raw));
904
+ } catch (err) {
905
+ this.logger?.("warn", `cache_write_failed key=${key}`, err);
906
+ }
800
907
  }
801
908
 
802
909
  /**
@@ -822,7 +929,7 @@ export class Room {
822
929
  };
823
930
  }
824
931
 
825
- /** Phase D #2 — 방 내 메시지 검색.
932
+ /** 방 내 메시지 검색.
826
933
  *
827
934
  * ``content`` 필드에 대한 대소문자 무시 부분 문자열 매칭 (ILIKE). 삭제된
828
935
  * 메시지는 제외. 결과는 최신순 (message_id DESC) 으로 반환되고, ``before``
@@ -44,6 +44,7 @@ export interface UnreadRoomItem {
44
44
  lastMessageId?: string | null;
45
45
  lastReadMessageId?: string | null;
46
46
  lastMessagePreview?: string | null;
47
+ lastMessageAt?: number | null;
47
48
  memberCount?: number;
48
49
  membersPreview?: MemberPreview[];
49
50
  }
@@ -57,6 +58,7 @@ export function parseUnreadRoomItem(j: any): UnreadRoomItem {
57
58
  lastMessageId: j["last_message_id"],
58
59
  lastReadMessageId: j["last_read_message_id"],
59
60
  lastMessagePreview: j["last_message_preview"],
61
+ lastMessageAt: j["last_message_at"],
60
62
  memberCount: j["member_count"],
61
63
  membersPreview: j["members_preview"] == null ? undefined : (j["members_preview"] as unknown[]).map((e: any) => parseMemberPreview(e)),
62
64
  } as UnreadRoomItem;
@@ -155,6 +157,7 @@ export interface MessageListResponse {
155
157
  items: MessageOut[];
156
158
  nextCursor?: string | null;
157
159
  hasMore: boolean;
160
+ historyFrom?: number | null;
158
161
  }
159
162
 
160
163
  export function parseMessageListResponse(j: any): MessageListResponse {
@@ -162,6 +165,7 @@ export function parseMessageListResponse(j: any): MessageListResponse {
162
165
  items: (j["items"] as unknown[]).map((e: any) => parseMessageOut(e)),
163
166
  nextCursor: j["next_cursor"],
164
167
  hasMore: j["has_more"],
168
+ historyFrom: j["history_from"],
165
169
  } as MessageListResponse;
166
170
  }
167
171
 
@@ -489,6 +493,20 @@ export function parseJoinRequestCreateResponse(j: any): JoinRequestCreateRespons
489
493
  } as JoinRequestCreateResponse;
490
494
  }
491
495
 
496
+ export interface RoomFilesResponse {
497
+ items: RoomFileItem[];
498
+ nextCursor?: string | null;
499
+ hasMore?: boolean;
500
+ }
501
+
502
+ export function parseRoomFilesResponse(j: any): RoomFilesResponse {
503
+ return {
504
+ items: (j["items"] as unknown[]).map((e: any) => parseRoomFileItem(e)),
505
+ nextCursor: j["next_cursor"],
506
+ hasMore: j["has_more"],
507
+ } as RoomFilesResponse;
508
+ }
509
+
492
510
  /** Minimal sender identity — no friend/block/profile-url fluff. */
493
511
  export interface SenderMini {
494
512
  userId: string;
@@ -527,3 +545,49 @@ export type JoinPolicy = "OPEN" | "APPROVAL_REQUIRED";
527
545
  export type MemberRole = "OPERATOR" | "MEMBER" | "KICKED";
528
546
 
529
547
  export type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
548
+
549
+ /** 방에서 공유된 파일 1건 — 전달(forward)이면 메시지마다 한 번씩. */
550
+ export interface RoomFileItem {
551
+ messageId: string;
552
+ senderId: string;
553
+ createdAtMs: number;
554
+ file: RoomFileInfo;
555
+ }
556
+
557
+ export function parseRoomFileItem(j: any): RoomFileItem {
558
+ return {
559
+ messageId: j["message_id"],
560
+ senderId: j["sender_id"],
561
+ createdAtMs: j["created_at_ms"],
562
+ file: parseRoomFileInfo(j["file"]),
563
+ } as RoomFileItem;
564
+ }
565
+
566
+ /** 한 파일의 메타 (rooms/{id}/files 항목의 file 필드). */
567
+ export interface RoomFileInfo {
568
+ id: string;
569
+ kind: string;
570
+ mime?: string | null;
571
+ sizeBytes?: number | null;
572
+ originalName?: string | null;
573
+ width?: number | null;
574
+ height?: number | null;
575
+ durationSec?: number | null;
576
+ thumbnailStatus?: string | null;
577
+ forwardBlocked?: boolean;
578
+ }
579
+
580
+ export function parseRoomFileInfo(j: any): RoomFileInfo {
581
+ return {
582
+ id: j["id"],
583
+ kind: j["kind"],
584
+ mime: j["mime"],
585
+ sizeBytes: j["size_bytes"],
586
+ originalName: j["original_name"],
587
+ width: j["width"],
588
+ height: j["height"],
589
+ durationSec: j["duration_sec"],
590
+ thumbnailStatus: j["thumbnail_status"],
591
+ forwardBlocked: j["forward_blocked"],
592
+ } as RoomFileInfo;
593
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,23 @@
1
1
  export { NoveraChat } from "./client.js";
2
2
  export { Room, type RoomEvents } from "./features/room.js";
3
+
4
+ // 스냅샷 캐시 — 앱 주입 저장소 인터페이스 + 정책 + 키 규칙
5
+ export { CacheKeys, type CachePolicy, type CacheStore } from "./cache.js";
6
+
7
+ // 코어 소유 메시지 상태 + 뷰모델 (sdk-react에서 이동)
8
+ export {
9
+ MessageCollection,
10
+ type CollectionChange,
11
+ type CollectionChangeKind,
12
+ } from "./features/message-collection.js";
13
+ export {
14
+ chatMessageFromHistory,
15
+ chatMessageFromLive,
16
+ pendingChatMessage,
17
+ pendingFileChatMessage,
18
+ type ChatMessage,
19
+ type ChatMessageStatus,
20
+ } from "./chat-message.js";
3
21
  export {
4
22
  ErrorCode,
5
23
  NoveraChatError,
@@ -20,6 +38,7 @@ export type {
20
38
  MessageType,
21
39
  ReactionEntry,
22
40
  RoomMemberOut,
41
+ RoomOut,
23
42
  RoomUnread,
24
43
  SendParams,
25
44
  SenderMini,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Snowflake id 비교 — 63비트 정수가 문자열로 오므로 BigInt로 비교한다
3
+ * (길이가 다르면 사전순 `>`는 틀린다: "9" > "10"). null/빈 문자열/"0"은
4
+ * "id 없음 = -∞"로 취급.
5
+ */
6
+ export function idGreater(
7
+ a: string | null | undefined,
8
+ b: string | null | undefined,
9
+ ): boolean {
10
+ if (!a || a === "0") return false;
11
+ if (!b || b === "0") return true;
12
+ try {
13
+ return BigInt(a) > BigInt(b);
14
+ } catch {
15
+ return a > b;
16
+ }
17
+ }
package/src/types.ts CHANGED
@@ -152,8 +152,7 @@ export interface SendParams {
152
152
  forwardBlocked?: boolean;
153
153
  replyToId?: MessageIdStr;
154
154
  /** Set when this send is forwarding an existing message. The server
155
- * uses it to enforce the source file's ``forwardBlocked`` flag (Phase
156
- * C #7): if the source's file is no-forward AND the destination room
155
+ * uses it to enforce the source file's ``forwardBlocked`` flag: if the source's file is no-forward AND the destination room
157
156
  * differs from the source room, the send is rejected with
158
157
  * ``NC-FILE-014``. Pass the ORIGINAL message's ``messageId``. */
159
158
  forwardOfMessageId?: MessageIdStr;
@@ -238,11 +237,23 @@ export interface FilePresignResponse {
238
237
 
239
238
  // (wire frame types are generated — see re-export block near the top)
240
239
 
240
+ import type { CachePolicy, CacheStore } from "./cache.js";
241
+
241
242
  export interface ClientOptions {
242
243
  appId: string;
243
244
  endpoint: string; // e.g. "https://chat.example.com"
244
245
  wsEndpoint?: string; // defaults to endpoint with ws(s)://
245
246
  tokenProvider: () => Promise<string>;
247
+ /**
248
+ * 스냅샷 캐시 저장소 — 생략(기본)하면 캐시 기능이 전부 꺼진다.
249
+ * 주입하면 방 목록·방 히스토리 최신 페이지가 REST 원문 그대로 저장되고,
250
+ * `cachedUnreadSummary()` / `room.cachedRecent()` 로 콜드 스타트 즉시
251
+ * 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
252
+ */
253
+ cacheStore?: CacheStore;
254
+ /** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
255
+ * `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
256
+ cachePolicy?: CachePolicy;
246
257
  autoReconnect?: boolean; // default true
247
258
  pingIntervalMs?: number; // default 20_000
248
259
  readWatermarkDebounceMs?: number;// default 1500