@noverachat/sdk-web 0.4.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.
package/dist/index.cjs CHANGED
@@ -58,6 +58,7 @@ function parseUnreadRoomItem(j) {
58
58
  lastMessageId: j["last_message_id"],
59
59
  lastReadMessageId: j["last_read_message_id"],
60
60
  lastMessagePreview: j["last_message_preview"],
61
+ lastMessageAt: j["last_message_at"],
61
62
  memberCount: j["member_count"],
62
63
  membersPreview: j["members_preview"] == null ? void 0 : j["members_preview"].map((e) => parseMemberPreview(e))
63
64
  };
@@ -812,13 +813,14 @@ var MessageCollection = class {
812
813
  this.emit("hydrated");
813
814
  }
814
815
  /**
815
- * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
816
- * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
817
- * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
816
+ * API 최신 페이지를 컬렉션에 반영한다.
817
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
818
+ * 스냅샷의 메시지를 보존·병합한다(`mergeFresh`).
818
819
  */
819
820
  applyFresh(page) {
820
- if (this.policy === "mergeDiff") {
821
- throw new Error('CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uC11C\uBC84 \uBB34\uD6A8\uD654 \uADDC\uCE59 \uD655\uC815 \uD6C4 \uC9C0\uC6D0 \uC608\uC815');
821
+ if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
822
+ this.mergeFresh(page);
823
+ return;
822
824
  }
823
825
  if (this.hydratedCount > 0) {
824
826
  this._messages.splice(0, this.hydratedCount);
@@ -829,6 +831,45 @@ var MessageCollection = class {
829
831
  this._hasMore = page.hasMore;
830
832
  this.emit("replaced");
831
833
  }
834
+ /**
835
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
836
+ *
837
+ * 판정 규칙(서버 계약, 07-23 확정):
838
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
839
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
840
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
841
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
842
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
843
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
844
+ *
845
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
846
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
847
+ */
848
+ mergeFresh(page) {
849
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
850
+ this._messages.splice(0, this.hydratedCount);
851
+ this.hydratedCount = 0;
852
+ const freshIds = new Set(page.items.map((m) => m.messageId));
853
+ const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
854
+ const hf = page.historyFrom ?? null;
855
+ const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
856
+ let preserved = [];
857
+ if (overlaps && page.hasMore) {
858
+ preserved = cachedHead.filter((m) => {
859
+ if (freshIds.has(m.id)) return false;
860
+ if (!idGreater(freshOldest, m.id)) return false;
861
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
862
+ return false;
863
+ }
864
+ return true;
865
+ });
866
+ }
867
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
868
+ this._messages.unshift(...preserved);
869
+ this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
870
+ this._hasMore = page.hasMore;
871
+ this.emit("replaced");
872
+ }
832
873
  // ── optimistic sends ────────────────────────────────────────────
833
874
  /**
834
875
  * Send `text` optimistically: a `sending` bubble appears immediately (one
@@ -1141,9 +1182,6 @@ var Room = class _Room {
1141
1182
  cacheStore;
1142
1183
  cachePolicy;
1143
1184
  logger;
1144
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
1145
- timerSeconds = null;
1146
- timerKnown = false;
1147
1185
  /**
1148
1186
  * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
1149
1187
  *
@@ -1621,7 +1659,8 @@ var Room = class _Room {
1621
1659
  items: raw.items.map(toMessageOut),
1622
1660
  // 파싱 성공한 응답만 캐시에 넣는다
1623
1661
  nextCursor: raw.next_cursor,
1624
- hasMore: raw.has_more
1662
+ hasMore: raw.has_more,
1663
+ historyFrom: raw.history_from ?? null
1625
1664
  };
1626
1665
  void this.cacheWriteRecent(raw);
1627
1666
  return page;
@@ -1632,10 +1671,7 @@ var Room = class _Room {
1632
1671
  "GET",
1633
1672
  `/api/v1/rooms/${this.roomId}`
1634
1673
  );
1635
- const out = parseRoomOut(raw);
1636
- this.timerSeconds = out.timerSeconds;
1637
- this.timerKnown = true;
1638
- return out;
1674
+ return parseRoomOut(raw);
1639
1675
  }
1640
1676
  /**
1641
1677
  * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
@@ -1657,7 +1693,8 @@ var Room = class _Room {
1657
1693
  return {
1658
1694
  items: j.items.map(toMessageOut),
1659
1695
  nextCursor: j.next_cursor,
1660
- hasMore: j.has_more
1696
+ hasMore: j.has_more,
1697
+ historyFrom: j.history_from ?? null
1661
1698
  };
1662
1699
  } catch (err) {
1663
1700
  this.logger?.("warn", `cache_read_failed key=${key}`, err);
@@ -1669,20 +1706,15 @@ var Room = class _Room {
1669
1706
  }
1670
1707
  }
1671
1708
  /**
1672
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1673
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1674
- * 살아남는 보안 충돌을 원천 차단한다.
1709
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1710
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1711
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1675
1712
  */
1676
1713
  async cacheWriteRecent(raw) {
1677
1714
  const store = this.cacheStore;
1678
1715
  if (!store) return;
1679
1716
  const key = CacheKeys.msgs(this.roomId);
1680
1717
  try {
1681
- if (!this.timerKnown) await this.detail();
1682
- if (this.timerSeconds != null) {
1683
- await store.remove(key);
1684
- return;
1685
- }
1686
1718
  await store.write(key, JSON.stringify(raw));
1687
1719
  } catch (err) {
1688
1720
  this.logger?.("warn", `cache_write_failed key=${key}`, err);
@@ -2076,11 +2108,6 @@ var NoveraChat = class {
2076
2108
  if (!opts.appId) throw new Error("appId required");
2077
2109
  if (!opts.endpoint) throw new Error("endpoint required");
2078
2110
  if (!opts.tokenProvider) throw new Error("tokenProvider required");
2079
- if (opts.cachePolicy === "mergeDiff") {
2080
- throw new Error(
2081
- 'CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uD604\uC7AC "replaceByApi"\uB9CC \uC9C0\uC6D0'
2082
- );
2083
- }
2084
2111
  this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
2085
2112
  this.logger = opts.logger;
2086
2113
  if (opts.initialLastMessageIds) {
package/dist/index.d.cts CHANGED
@@ -225,6 +225,7 @@ interface UnreadRoomItem {
225
225
  lastMessageId?: string | null;
226
226
  lastReadMessageId?: string | null;
227
227
  lastMessagePreview?: string | null;
228
+ lastMessageAt?: number | null;
228
229
  memberCount?: number;
229
230
  membersPreview?: MemberPreview[];
230
231
  }
@@ -380,11 +381,12 @@ type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
380
381
  *
381
382
  * 원칙:
382
383
  * 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
383
- * API 도착 시 통째 교체(diff 병합 없음).
384
+ * API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
385
+ * fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
384
386
  * 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
385
387
  * 구현은 앱이 주입(localStorage/IndexedDB 등).
386
388
  * 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
387
- * 시 해당 키를 드랍. 삭제 타이머 켜진 방은 캐시 대상에서 제외.
389
+ * 시 해당 키를 드랍.
388
390
  */
389
391
  /**
390
392
  * 앱이 주입하는 key-value 스냅샷 저장소.
@@ -417,9 +419,10 @@ interface CacheStore {
417
419
  /**
418
420
  * 캐시 스냅샷과 API 응답을 어떻게 합칠지.
419
421
  *
420
- * 현재는 `"replaceByApi"`만 구현되어 있다 `"mergeDiff"`는 서버
421
- * 무효화 규칙(삭제 타이머 만료 통지·재입장 컷오프 등)이 확정된
422
- * 병합 함수가 채워질 진입점이다.
422
+ * - `"replaceByApi"` (기본)스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
423
+ * - `"mergeDiff"` fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
424
+ * 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
425
+ * 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
423
426
  */
424
427
  type CachePolicy = "replaceByApi" | "mergeDiff";
425
428
  /**
@@ -597,7 +600,8 @@ interface ClientOptions {
597
600
  * 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
598
601
  */
599
602
  cacheStore?: CacheStore;
600
- /** 캐시 스냅샷과 API 응답의 합성 방식. 현재 `"replaceByApi"`만 지원. */
603
+ /** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
604
+ * `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
601
605
  cachePolicy?: CachePolicy;
602
606
  autoReconnect?: boolean;
603
607
  pingIntervalMs?: number;
@@ -760,8 +764,8 @@ declare function pendingFileChatMessage(p: {
760
764
  * RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
761
765
  * 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
762
766
  * 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
763
- * 코어가 강제한다. 추후 diff 병합은 `applyFresh`의 mergeDiff 분기
764
- * 하나로 들어온다.
767
+ * 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
768
+ * 들어온다.
765
769
  */
766
770
 
767
771
  /**
@@ -854,11 +858,26 @@ declare class MessageCollection {
854
858
  * 힌트일 뿐). */
855
859
  private hydrateFromCache;
856
860
  /**
857
- * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
858
- * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
859
- * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
861
+ * API 최신 페이지를 컬렉션에 반영한다.
862
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
863
+ * 스냅샷의 메시지를 보존·병합한다(`mergeFresh`).
860
864
  */
861
865
  private applyFresh;
866
+ /**
867
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
868
+ *
869
+ * 판정 규칙(서버 계약, 07-23 확정):
870
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
871
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
872
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
873
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
874
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
875
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
876
+ *
877
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
878
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
879
+ */
880
+ private mergeFresh;
862
881
  /**
863
882
  * Send `text` optimistically: a `sending` bubble appears immediately (one
864
883
  * microtask later — `Room.send` is async), then flips to `sent` (with the
@@ -946,9 +965,6 @@ declare class Room {
946
965
  private readonly cacheStore;
947
966
  private readonly cachePolicy;
948
967
  private readonly logger;
949
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
950
- private timerSeconds;
951
- private timerKnown;
952
968
  constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
953
969
  cacheStore?: CacheStore;
954
970
  cachePolicy?: CachePolicy;
@@ -1150,6 +1166,8 @@ declare class Room {
1150
1166
  items: MessageOut[];
1151
1167
  nextCursor: MessageIdStr | null;
1152
1168
  hasMore: boolean;
1169
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
1170
+ historyFrom: number | null;
1153
1171
  }>;
1154
1172
  /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1155
1173
  detail(): Promise<RoomOut>;
@@ -1166,11 +1184,12 @@ declare class Room {
1166
1184
  items: MessageOut[];
1167
1185
  nextCursor: MessageIdStr | null;
1168
1186
  hasMore: boolean;
1187
+ historyFrom: number | null;
1169
1188
  } | null>;
1170
1189
  /**
1171
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1172
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1173
- * 살아남는 보안 충돌을 원천 차단한다.
1190
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1191
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1192
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1174
1193
  */
1175
1194
  private cacheWriteRecent;
1176
1195
  /**
package/dist/index.d.ts CHANGED
@@ -225,6 +225,7 @@ interface UnreadRoomItem {
225
225
  lastMessageId?: string | null;
226
226
  lastReadMessageId?: string | null;
227
227
  lastMessagePreview?: string | null;
228
+ lastMessageAt?: number | null;
228
229
  memberCount?: number;
229
230
  membersPreview?: MemberPreview[];
230
231
  }
@@ -380,11 +381,12 @@ type PushTrigger = "ALL" | "MENTION_ONLY" | "OFF";
380
381
  *
381
382
  * 원칙:
382
383
  * 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
383
- * API 도착 시 통째 교체(diff 병합 없음).
384
+ * API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
385
+ * fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
384
386
  * 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
385
387
  * 구현은 앱이 주입(localStorage/IndexedDB 등).
386
388
  * 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
387
- * 시 해당 키를 드랍. 삭제 타이머 켜진 방은 캐시 대상에서 제외.
389
+ * 시 해당 키를 드랍.
388
390
  */
389
391
  /**
390
392
  * 앱이 주입하는 key-value 스냅샷 저장소.
@@ -417,9 +419,10 @@ interface CacheStore {
417
419
  /**
418
420
  * 캐시 스냅샷과 API 응답을 어떻게 합칠지.
419
421
  *
420
- * 현재는 `"replaceByApi"`만 구현되어 있다 `"mergeDiff"`는 서버
421
- * 무효화 규칙(삭제 타이머 만료 통지·재입장 컷오프 등)이 확정된
422
- * 병합 함수가 채워질 진입점이다.
422
+ * - `"replaceByApi"` (기본)스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
423
+ * - `"mergeDiff"` fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
424
+ * 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
425
+ * 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
423
426
  */
424
427
  type CachePolicy = "replaceByApi" | "mergeDiff";
425
428
  /**
@@ -597,7 +600,8 @@ interface ClientOptions {
597
600
  * 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
598
601
  */
599
602
  cacheStore?: CacheStore;
600
- /** 캐시 스냅샷과 API 응답의 합성 방식. 현재 `"replaceByApi"`만 지원. */
603
+ /** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
604
+ * `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
601
605
  cachePolicy?: CachePolicy;
602
606
  autoReconnect?: boolean;
603
607
  pingIntervalMs?: number;
@@ -760,8 +764,8 @@ declare function pendingFileChatMessage(p: {
760
764
  * RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
761
765
  * 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
762
766
  * 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
763
- * 코어가 강제한다. 추후 diff 병합은 `applyFresh`의 mergeDiff 분기
764
- * 하나로 들어온다.
767
+ * 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
768
+ * 들어온다.
765
769
  */
766
770
 
767
771
  /**
@@ -854,11 +858,26 @@ declare class MessageCollection {
854
858
  * 힌트일 뿐). */
855
859
  private hydrateFromCache;
856
860
  /**
857
- * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
858
- * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
859
- * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
861
+ * API 최신 페이지를 컬렉션에 반영한다.
862
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
863
+ * 스냅샷의 메시지를 보존·병합한다(`mergeFresh`).
860
864
  */
861
865
  private applyFresh;
866
+ /**
867
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
868
+ *
869
+ * 판정 규칙(서버 계약, 07-23 확정):
870
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
871
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
872
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
873
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
874
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
875
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
876
+ *
877
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
878
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
879
+ */
880
+ private mergeFresh;
862
881
  /**
863
882
  * Send `text` optimistically: a `sending` bubble appears immediately (one
864
883
  * microtask later — `Room.send` is async), then flips to `sent` (with the
@@ -946,9 +965,6 @@ declare class Room {
946
965
  private readonly cacheStore;
947
966
  private readonly cachePolicy;
948
967
  private readonly logger;
949
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
950
- private timerSeconds;
951
- private timerKnown;
952
968
  constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
953
969
  cacheStore?: CacheStore;
954
970
  cachePolicy?: CachePolicy;
@@ -1150,6 +1166,8 @@ declare class Room {
1150
1166
  items: MessageOut[];
1151
1167
  nextCursor: MessageIdStr | null;
1152
1168
  hasMore: boolean;
1169
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
1170
+ historyFrom: number | null;
1153
1171
  }>;
1154
1172
  /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1155
1173
  detail(): Promise<RoomOut>;
@@ -1166,11 +1184,12 @@ declare class Room {
1166
1184
  items: MessageOut[];
1167
1185
  nextCursor: MessageIdStr | null;
1168
1186
  hasMore: boolean;
1187
+ historyFrom: number | null;
1169
1188
  } | null>;
1170
1189
  /**
1171
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1172
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1173
- * 살아남는 보안 충돌을 원천 차단한다.
1190
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1191
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1192
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1174
1193
  */
1175
1194
  private cacheWriteRecent;
1176
1195
  /**
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ function parseUnreadRoomItem(j) {
23
23
  lastMessageId: j["last_message_id"],
24
24
  lastReadMessageId: j["last_read_message_id"],
25
25
  lastMessagePreview: j["last_message_preview"],
26
+ lastMessageAt: j["last_message_at"],
26
27
  memberCount: j["member_count"],
27
28
  membersPreview: j["members_preview"] == null ? void 0 : j["members_preview"].map((e) => parseMemberPreview(e))
28
29
  };
@@ -777,13 +778,14 @@ var MessageCollection = class {
777
778
  this.emit("hydrated");
778
779
  }
779
780
  /**
780
- * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
781
- * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
782
- * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
781
+ * API 최신 페이지를 컬렉션에 반영한다.
782
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
783
+ * 스냅샷의 메시지를 보존·병합한다(`mergeFresh`).
783
784
  */
784
785
  applyFresh(page) {
785
- if (this.policy === "mergeDiff") {
786
- throw new Error('CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uC11C\uBC84 \uBB34\uD6A8\uD654 \uADDC\uCE59 \uD655\uC815 \uD6C4 \uC9C0\uC6D0 \uC608\uC815');
786
+ if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
787
+ this.mergeFresh(page);
788
+ return;
787
789
  }
788
790
  if (this.hydratedCount > 0) {
789
791
  this._messages.splice(0, this.hydratedCount);
@@ -794,6 +796,45 @@ var MessageCollection = class {
794
796
  this._hasMore = page.hasMore;
795
797
  this.emit("replaced");
796
798
  }
799
+ /**
800
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
801
+ *
802
+ * 판정 규칙(서버 계약, 07-23 확정):
803
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
804
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
805
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
806
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
807
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
808
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
809
+ *
810
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
811
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
812
+ */
813
+ mergeFresh(page) {
814
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
815
+ this._messages.splice(0, this.hydratedCount);
816
+ this.hydratedCount = 0;
817
+ const freshIds = new Set(page.items.map((m) => m.messageId));
818
+ const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
819
+ const hf = page.historyFrom ?? null;
820
+ const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
821
+ let preserved = [];
822
+ if (overlaps && page.hasMore) {
823
+ preserved = cachedHead.filter((m) => {
824
+ if (freshIds.has(m.id)) return false;
825
+ if (!idGreater(freshOldest, m.id)) return false;
826
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
827
+ return false;
828
+ }
829
+ return true;
830
+ });
831
+ }
832
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
833
+ this._messages.unshift(...preserved);
834
+ this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
835
+ this._hasMore = page.hasMore;
836
+ this.emit("replaced");
837
+ }
797
838
  // ── optimistic sends ────────────────────────────────────────────
798
839
  /**
799
840
  * Send `text` optimistically: a `sending` bubble appears immediately (one
@@ -1106,9 +1147,6 @@ var Room = class _Room {
1106
1147
  cacheStore;
1107
1148
  cachePolicy;
1108
1149
  logger;
1109
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
1110
- timerSeconds = null;
1111
- timerKnown = false;
1112
1150
  /**
1113
1151
  * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
1114
1152
  *
@@ -1586,7 +1624,8 @@ var Room = class _Room {
1586
1624
  items: raw.items.map(toMessageOut),
1587
1625
  // 파싱 성공한 응답만 캐시에 넣는다
1588
1626
  nextCursor: raw.next_cursor,
1589
- hasMore: raw.has_more
1627
+ hasMore: raw.has_more,
1628
+ historyFrom: raw.history_from ?? null
1590
1629
  };
1591
1630
  void this.cacheWriteRecent(raw);
1592
1631
  return page;
@@ -1597,10 +1636,7 @@ var Room = class _Room {
1597
1636
  "GET",
1598
1637
  `/api/v1/rooms/${this.roomId}`
1599
1638
  );
1600
- const out = parseRoomOut(raw);
1601
- this.timerSeconds = out.timerSeconds;
1602
- this.timerKnown = true;
1603
- return out;
1639
+ return parseRoomOut(raw);
1604
1640
  }
1605
1641
  /**
1606
1642
  * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
@@ -1622,7 +1658,8 @@ var Room = class _Room {
1622
1658
  return {
1623
1659
  items: j.items.map(toMessageOut),
1624
1660
  nextCursor: j.next_cursor,
1625
- hasMore: j.has_more
1661
+ hasMore: j.has_more,
1662
+ historyFrom: j.history_from ?? null
1626
1663
  };
1627
1664
  } catch (err) {
1628
1665
  this.logger?.("warn", `cache_read_failed key=${key}`, err);
@@ -1634,20 +1671,15 @@ var Room = class _Room {
1634
1671
  }
1635
1672
  }
1636
1673
  /**
1637
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1638
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1639
- * 살아남는 보안 충돌을 원천 차단한다.
1674
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1675
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1676
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1640
1677
  */
1641
1678
  async cacheWriteRecent(raw) {
1642
1679
  const store = this.cacheStore;
1643
1680
  if (!store) return;
1644
1681
  const key = CacheKeys.msgs(this.roomId);
1645
1682
  try {
1646
- if (!this.timerKnown) await this.detail();
1647
- if (this.timerSeconds != null) {
1648
- await store.remove(key);
1649
- return;
1650
- }
1651
1683
  await store.write(key, JSON.stringify(raw));
1652
1684
  } catch (err) {
1653
1685
  this.logger?.("warn", `cache_write_failed key=${key}`, err);
@@ -2041,11 +2073,6 @@ var NoveraChat = class {
2041
2073
  if (!opts.appId) throw new Error("appId required");
2042
2074
  if (!opts.endpoint) throw new Error("endpoint required");
2043
2075
  if (!opts.tokenProvider) throw new Error("tokenProvider required");
2044
- if (opts.cachePolicy === "mergeDiff") {
2045
- throw new Error(
2046
- 'CachePolicy "mergeDiff" \uB294 \uC544\uC9C1 \uBBF8\uAD6C\uD604 \u2014 \uD604\uC7AC "replaceByApi"\uB9CC \uC9C0\uC6D0'
2047
- );
2048
- }
2049
2076
  this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
2050
2077
  this.logger = opts.logger;
2051
2078
  if (opts.initialLastMessageIds) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noverachat/sdk-web",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "NoveraChat browser/Node SDK — WebSocket + REST client with optimistic UI, reconnection and gap-fill",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/cache.ts CHANGED
@@ -4,11 +4,12 @@
4
4
  *
5
5
  * 원칙:
6
6
  * 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
7
- * API 도착 시 통째 교체(diff 병합 없음).
7
+ * API 도착 시 기본 통째 교체(replaceByApi). 옵트인 mergeDiff는
8
+ * fresh보다 오래된 메시지만 보존하는 병합(서버를 덮어쓰지 않음).
8
9
  * 2. 저장소는 SDK가 정하지 않는다 — `CacheStore` 인터페이스만 정의,
9
10
  * 구현은 앱이 주입(localStorage/IndexedDB 등).
10
11
  * 3. 버전 스탬프 + 통째 폐기 — 키에 스키마 버전(`nc.v1.…`), 파싱 실패
11
- * 시 해당 키를 드랍. 삭제 타이머 켜진 방은 캐시 대상에서 제외.
12
+ * 시 해당 키를 드랍.
12
13
  */
13
14
 
14
15
  /**
@@ -43,9 +44,10 @@ export interface CacheStore {
43
44
  /**
44
45
  * 캐시 스냅샷과 API 응답을 어떻게 합칠지.
45
46
  *
46
- * 현재는 `"replaceByApi"`만 구현되어 있다 `"mergeDiff"`는 서버
47
- * 무효화 규칙(삭제 타이머 만료 통지·재입장 컷오프 등)이 확정된
48
- * 병합 함수가 채워질 진입점이다.
47
+ * - `"replaceByApi"` (기본)스냅샷 즉시 렌더 후 API 도착 시 통째 교체.
48
+ * - `"mergeDiff"` fresh 페이지보다 오래된 캐시 메시지를 보존·병합해 더
49
+ * 연속 히스토리 유지. `history_from`으로 컷오프분을, fresh 대조로
50
+ * 삭제분을 걸러내고, 캐시·fresh 사이 갭(겹침 없음)이면 통째 교체 폴백.
49
51
  */
50
52
  export type CachePolicy = "replaceByApi" | "mergeDiff";
51
53
 
package/src/client.ts CHANGED
@@ -68,11 +68,6 @@ export class NoveraChat {
68
68
  if (!opts.appId) throw new Error("appId required");
69
69
  if (!opts.endpoint) throw new Error("endpoint required");
70
70
  if (!opts.tokenProvider) throw new Error("tokenProvider required");
71
- if (opts.cachePolicy === "mergeDiff") {
72
- throw new Error(
73
- 'CachePolicy "mergeDiff" 는 아직 미구현 — 현재 "replaceByApi"만 지원',
74
- );
75
- }
76
71
 
77
72
  // 400ms is the read-receipt-UX sweet spot for chat apps. Shorter and
78
73
  // we waste WS frames on every micro-scroll; longer and the "✓ read"
@@ -4,8 +4,8 @@
4
4
  * RoomStore(sdk-react)에 있던 이벤트 반영·낙관적 전송·캐시 hydrate→교체
5
5
  * 로직의 코어 이관본. 바인딩(React·Flutter)은 이 컬렉션을 감싸는 얇은
6
6
  * 접착층이 되고, "캐시는 렌더 힌트, 서버가 항상 이긴다" 같은 불변식은
7
- * 코어가 강제한다. 추후 diff 병합은 `applyFresh`의 mergeDiff 분기
8
- * 하나로 들어온다.
7
+ * 코어가 강제한다. diff 병합(mergeDiff)은 `applyFresh`→`mergeFresh`로
8
+ * 들어온다.
9
9
  */
10
10
 
11
11
  import type {
@@ -205,17 +205,19 @@ export class MessageCollection {
205
205
  }
206
206
 
207
207
  /**
208
- * API 최신 페이지를 컬렉션에 반영한다 — **diff 병합 진입점**.
209
- * replaceByApi(현재 유일 정책)는 스냅샷을 걷어내고 통째 교체하고,
210
- * mergeDiff는 서버 무효화 규칙 확정 후 이 분기 안에 병합 함수가 채워진다.
208
+ * API 최신 페이지를 컬렉션에 반영한다.
209
+ * replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
210
+ * 스냅샷의 메시지를 보존·병합한다(`mergeFresh`).
211
211
  */
212
212
  private applyFresh(page: {
213
213
  items: MessageOut[];
214
214
  nextCursor: MessageIdStr | null;
215
215
  hasMore: boolean;
216
+ historyFrom?: number | null;
216
217
  }): void {
217
- if (this.policy === "mergeDiff") {
218
- throw new Error('CachePolicy "mergeDiff" 는 아직 미구현 — 서버 무효화 규칙 확정 후 지원 예정');
218
+ if (this.policy === "mergeDiff" && this.hydratedCount > 0) {
219
+ this.mergeFresh(page);
220
+ return;
219
221
  }
220
222
  if (this.hydratedCount > 0) {
221
223
  this._messages.splice(0, this.hydratedCount); // 스냅샷 통째 폐기 → 서버가 이긴다
@@ -227,6 +229,61 @@ export class MessageCollection {
227
229
  this.emit("replaced");
228
230
  }
229
231
 
232
+ /**
233
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
234
+ *
235
+ * 판정 규칙(서버 계약, 07-23 확정):
236
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
237
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
238
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
239
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
240
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
241
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
242
+ *
243
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
244
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
245
+ */
246
+ private mergeFresh(page: {
247
+ items: MessageOut[];
248
+ nextCursor: MessageIdStr | null;
249
+ hasMore: boolean;
250
+ historyFrom?: number | null;
251
+ }): void {
252
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
253
+ this._messages.splice(0, this.hydratedCount);
254
+ this.hydratedCount = 0;
255
+
256
+ const freshIds = new Set(page.items.map((m) => m.messageId));
257
+ const freshOldest = page.items.length > 0 ? page.items[0]!.messageId : null;
258
+ const hf = page.historyFrom ?? null;
259
+
260
+ // 갭 폴백: 캐시 최신이 fresh에 없으면 연속성 보장 불가 → 통째 교체.
261
+ // fresh가 방 전체(hasMore=false)인데 범위 밖 캐시가 남는 경우도 유령이므로 교체.
262
+ const overlaps =
263
+ cachedHead.length > 0 &&
264
+ freshOldest != null &&
265
+ freshIds.has(cachedHead.at(-1)!.id);
266
+ let preserved: ChatMessage[] = [];
267
+ if (overlaps && page.hasMore) {
268
+ preserved = cachedHead.filter((m) => {
269
+ if (freshIds.has(m.id)) return false; // 겹침 → fresh가 대체
270
+ if (!idGreater(freshOldest, m.id)) return false; // fresh 구간 내 부재 → 삭제분
271
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
272
+ return false; // 컷오프분
273
+ }
274
+ return true;
275
+ });
276
+ }
277
+
278
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
279
+ this._messages.unshift(...preserved);
280
+ // 보존분이 있으면 스크롤백 커서를 보존분의 최저 id로 — 중복 로드 방지.
281
+ this.oldestCursor =
282
+ preserved.length > 0 ? preserved[0]!.id : page.nextCursor;
283
+ this._hasMore = page.hasMore;
284
+ this.emit("replaced");
285
+ }
286
+
230
287
  // ── optimistic sends ────────────────────────────────────────────
231
288
 
232
289
  /**
@@ -205,10 +205,6 @@ export class Room {
205
205
  private readonly cacheStore: CacheStore | null;
206
206
  private readonly cachePolicy: CachePolicy;
207
207
  private readonly logger: ClientOptions["logger"];
208
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
209
- private timerSeconds: number | null = null;
210
- private timerKnown = false;
211
-
212
208
  constructor(
213
209
  public readonly roomId: string,
214
210
  private http: HttpClient,
@@ -827,14 +823,18 @@ export class Room {
827
823
  items: MessageOut[];
828
824
  nextCursor: MessageIdStr | null;
829
825
  hasMore: boolean;
826
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
827
+ historyFrom: number | null;
830
828
  }> {
831
829
  const raw = await this.http.request<{
832
830
  items: unknown[]; next_cursor: string | null; has_more: boolean;
831
+ history_from?: number | null;
833
832
  }>("GET", `/api/v1/rooms/${this.roomId}/messages`, undefined, { limit });
834
833
  const page = {
835
834
  items: raw.items.map(toMessageOut), // 파싱 성공한 응답만 캐시에 넣는다
836
835
  nextCursor: raw.next_cursor,
837
836
  hasMore: raw.has_more,
837
+ historyFrom: raw.history_from ?? null,
838
838
  };
839
839
  void this.cacheWriteRecent(raw);
840
840
  return page;
@@ -845,10 +845,7 @@ export class Room {
845
845
  const raw = await this.http.request<unknown>(
846
846
  "GET", `/api/v1/rooms/${this.roomId}`,
847
847
  );
848
- const out = parseRoomOut(raw);
849
- this.timerSeconds = out.timerSeconds;
850
- this.timerKnown = true;
851
- return out;
848
+ return parseRoomOut(raw);
852
849
  }
853
850
 
854
851
  /**
@@ -864,6 +861,7 @@ export class Room {
864
861
  items: MessageOut[];
865
862
  nextCursor: MessageIdStr | null;
866
863
  hasMore: boolean;
864
+ historyFrom: number | null;
867
865
  } | null> {
868
866
  const store = this.cacheStore;
869
867
  if (!store) return null;
@@ -873,11 +871,13 @@ export class Room {
873
871
  if (raw == null) return null;
874
872
  const j = JSON.parse(raw) as {
875
873
  items: unknown[]; next_cursor: string | null; has_more: boolean;
874
+ history_from?: number | null;
876
875
  };
877
876
  return {
878
877
  items: j.items.map(toMessageOut),
879
878
  nextCursor: j.next_cursor,
880
879
  hasMore: j.has_more,
880
+ historyFrom: j.history_from ?? null,
881
881
  };
882
882
  } catch (err) {
883
883
  this.logger?.("warn", `cache_read_failed key=${key}`, err);
@@ -891,23 +891,17 @@ export class Room {
891
891
  }
892
892
 
893
893
  /**
894
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
895
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
896
- * 살아남는 보안 충돌을 원천 차단한다.
894
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
895
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
896
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
897
897
  */
898
898
  private async cacheWriteRecent(raw: unknown): Promise<void> {
899
899
  const store = this.cacheStore;
900
900
  if (!store) return;
901
901
  const key = CacheKeys.msgs(this.roomId);
902
902
  try {
903
- if (!this.timerKnown) await this.detail(); // 세션당 1회 — 이후는 메모 재사용
904
- if (this.timerSeconds != null) {
905
- await store.remove(key);
906
- return;
907
- }
908
903
  await store.write(key, JSON.stringify(raw));
909
904
  } catch (err) {
910
- // 타이머 확인 실패 포함 — 확신 없으면 저장하지 않는 쪽으로 기운다.
911
905
  this.logger?.("warn", `cache_write_failed key=${key}`, err);
912
906
  }
913
907
  }
@@ -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/types.ts CHANGED
@@ -251,7 +251,8 @@ export interface ClientOptions {
251
251
  * 렌더에 쓸 수 있다. 자세한 건 `CacheStore` 문서 참조.
252
252
  */
253
253
  cacheStore?: CacheStore;
254
- /** 캐시 스냅샷과 API 응답의 합성 방식. 현재 `"replaceByApi"`만 지원. */
254
+ /** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
255
+ * `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
255
256
  cachePolicy?: CachePolicy;
256
257
  autoReconnect?: boolean; // default true
257
258
  pingIntervalMs?: number; // default 20_000