@noverachat/sdk-web 0.4.0 → 0.5.1

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,23 +813,71 @@ 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);
825
827
  this.hydratedCount = 0;
826
828
  }
827
- this._messages.unshift(...page.items.map(chatMessageFromHistory));
829
+ const indexById = /* @__PURE__ */ new Map();
830
+ this._messages.forEach((m, i) => indexById.set(m.id, i));
831
+ const fresh = [];
832
+ for (const item of page.items.map(chatMessageFromHistory)) {
833
+ const at = indexById.get(item.id);
834
+ if (at === void 0) fresh.push(item);
835
+ else this._messages[at] = item;
836
+ }
837
+ this._messages.unshift(...fresh);
828
838
  this.oldestCursor = page.nextCursor;
829
839
  this._hasMore = page.hasMore;
830
840
  this.emit("replaced");
831
841
  }
842
+ /**
843
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
844
+ *
845
+ * 판정 규칙(서버 계약, 07-23 확정):
846
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
847
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
848
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
849
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
850
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
851
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
852
+ *
853
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
854
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
855
+ */
856
+ mergeFresh(page) {
857
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
858
+ this._messages.splice(0, this.hydratedCount);
859
+ this.hydratedCount = 0;
860
+ const freshIds = new Set(page.items.map((m) => m.messageId));
861
+ const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
862
+ const hf = page.historyFrom ?? null;
863
+ const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
864
+ let preserved = [];
865
+ if (overlaps && page.hasMore) {
866
+ preserved = cachedHead.filter((m) => {
867
+ if (freshIds.has(m.id)) return false;
868
+ if (!idGreater(freshOldest, m.id)) return false;
869
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
870
+ return false;
871
+ }
872
+ return true;
873
+ });
874
+ }
875
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
876
+ this._messages.unshift(...preserved);
877
+ this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
878
+ this._hasMore = page.hasMore;
879
+ this.emit("replaced");
880
+ }
832
881
  // ── optimistic sends ────────────────────────────────────────────
833
882
  /**
834
883
  * Send `text` optimistically: a `sending` bubble appears immediately (one
@@ -956,8 +1005,26 @@ var MessageCollection = class {
956
1005
  this.bus.removeAll();
957
1006
  }
958
1007
  // ── event handlers ──────────────────────────────────────────────
1008
+ /**
1009
+ * 수신 프레임을 컬렉션에 넣는다.
1010
+ *
1011
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
1012
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
1013
+ * 오래된 메시지도 함께 온다:
1014
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
1015
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
1016
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
1017
+ * 섞여 올 때만 탐색이 일어난다.
1018
+ */
959
1019
  onIncoming(f) {
960
- this._messages.push(chatMessageFromLive(f));
1020
+ const msg = chatMessageFromLive(f);
1021
+ let i = this._messages.length - 1;
1022
+ for (; i >= 0; i--) {
1023
+ const cur = this._messages[i];
1024
+ if (cur.id === msg.id) return;
1025
+ if (!idGreater(cur.id, msg.id)) break;
1026
+ }
1027
+ this._messages.splice(i + 1, 0, msg);
961
1028
  this.emit("inserted");
962
1029
  }
963
1030
  onUpdated(f) {
@@ -1141,9 +1208,6 @@ var Room = class _Room {
1141
1208
  cacheStore;
1142
1209
  cachePolicy;
1143
1210
  logger;
1144
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
1145
- timerSeconds = null;
1146
- timerKnown = false;
1147
1211
  /**
1148
1212
  * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
1149
1213
  *
@@ -1621,7 +1685,8 @@ var Room = class _Room {
1621
1685
  items: raw.items.map(toMessageOut),
1622
1686
  // 파싱 성공한 응답만 캐시에 넣는다
1623
1687
  nextCursor: raw.next_cursor,
1624
- hasMore: raw.has_more
1688
+ hasMore: raw.has_more,
1689
+ historyFrom: raw.history_from ?? null
1625
1690
  };
1626
1691
  void this.cacheWriteRecent(raw);
1627
1692
  return page;
@@ -1632,10 +1697,7 @@ var Room = class _Room {
1632
1697
  "GET",
1633
1698
  `/api/v1/rooms/${this.roomId}`
1634
1699
  );
1635
- const out = parseRoomOut(raw);
1636
- this.timerSeconds = out.timerSeconds;
1637
- this.timerKnown = true;
1638
- return out;
1700
+ return parseRoomOut(raw);
1639
1701
  }
1640
1702
  /**
1641
1703
  * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
@@ -1657,7 +1719,8 @@ var Room = class _Room {
1657
1719
  return {
1658
1720
  items: j.items.map(toMessageOut),
1659
1721
  nextCursor: j.next_cursor,
1660
- hasMore: j.has_more
1722
+ hasMore: j.has_more,
1723
+ historyFrom: j.history_from ?? null
1661
1724
  };
1662
1725
  } catch (err) {
1663
1726
  this.logger?.("warn", `cache_read_failed key=${key}`, err);
@@ -1669,20 +1732,15 @@ var Room = class _Room {
1669
1732
  }
1670
1733
  }
1671
1734
  /**
1672
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1673
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1674
- * 살아남는 보안 충돌을 원천 차단한다.
1735
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1736
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1737
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1675
1738
  */
1676
1739
  async cacheWriteRecent(raw) {
1677
1740
  const store = this.cacheStore;
1678
1741
  if (!store) return;
1679
1742
  const key = CacheKeys.msgs(this.roomId);
1680
1743
  try {
1681
- if (!this.timerKnown) await this.detail();
1682
- if (this.timerSeconds != null) {
1683
- await store.remove(key);
1684
- return;
1685
- }
1686
1744
  await store.write(key, JSON.stringify(raw));
1687
1745
  } catch (err) {
1688
1746
  this.logger?.("warn", `cache_write_failed key=${key}`, err);
@@ -2076,11 +2134,6 @@ var NoveraChat = class {
2076
2134
  if (!opts.appId) throw new Error("appId required");
2077
2135
  if (!opts.endpoint) throw new Error("endpoint required");
2078
2136
  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
2137
  this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
2085
2138
  this.logger = opts.logger;
2086
2139
  if (opts.initialLastMessageIds) {
@@ -2136,6 +2189,16 @@ var NoveraChat = class {
2136
2189
  readDebounceMs;
2137
2190
  logger;
2138
2191
  connectedOnce = false;
2192
+ /**
2193
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
2194
+ * `?since=`와 backfill 시작점.
2195
+ *
2196
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
2197
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
2198
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
2199
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
2200
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
2201
+ */
2139
2202
  lastMessageIdByRoom = /* @__PURE__ */ new Map();
2140
2203
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
2141
2204
  * Used to drop the N-1 shared-room copies of a single online/offline
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
@@ -893,6 +912,17 @@ declare class MessageCollection {
893
912
  /** Unsubscribe from the room and flush the pending read watermark. The
894
913
  * collection must not be used afterwards. */
895
914
  dispose(): void;
915
+ /**
916
+ * 수신 프레임을 컬렉션에 넣는다.
917
+ *
918
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
919
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
920
+ * 오래된 메시지도 함께 온다:
921
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
922
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
923
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
924
+ * 섞여 올 때만 탐색이 일어난다.
925
+ */
896
926
  private onIncoming;
897
927
  private onUpdated;
898
928
  private onDeleted;
@@ -946,9 +976,6 @@ declare class Room {
946
976
  private readonly cacheStore;
947
977
  private readonly cachePolicy;
948
978
  private readonly logger;
949
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
950
- private timerSeconds;
951
- private timerKnown;
952
979
  constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
953
980
  cacheStore?: CacheStore;
954
981
  cachePolicy?: CachePolicy;
@@ -1150,6 +1177,8 @@ declare class Room {
1150
1177
  items: MessageOut[];
1151
1178
  nextCursor: MessageIdStr | null;
1152
1179
  hasMore: boolean;
1180
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
1181
+ historyFrom: number | null;
1153
1182
  }>;
1154
1183
  /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1155
1184
  detail(): Promise<RoomOut>;
@@ -1166,11 +1195,12 @@ declare class Room {
1166
1195
  items: MessageOut[];
1167
1196
  nextCursor: MessageIdStr | null;
1168
1197
  hasMore: boolean;
1198
+ historyFrom: number | null;
1169
1199
  } | null>;
1170
1200
  /**
1171
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1172
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1173
- * 살아남는 보안 충돌을 원천 차단한다.
1201
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1202
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1203
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1174
1204
  */
1175
1205
  private cacheWriteRecent;
1176
1206
  /**
@@ -1367,6 +1397,16 @@ declare class NoveraChat {
1367
1397
  private readDebounceMs;
1368
1398
  private logger;
1369
1399
  private connectedOnce;
1400
+ /**
1401
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
1402
+ * `?since=`와 backfill 시작점.
1403
+ *
1404
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
1405
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
1406
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
1407
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
1408
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
1409
+ */
1370
1410
  private lastMessageIdByRoom;
1371
1411
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
1372
1412
  * Used to drop the N-1 shared-room copies of a single online/offline
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
@@ -893,6 +912,17 @@ declare class MessageCollection {
893
912
  /** Unsubscribe from the room and flush the pending read watermark. The
894
913
  * collection must not be used afterwards. */
895
914
  dispose(): void;
915
+ /**
916
+ * 수신 프레임을 컬렉션에 넣는다.
917
+ *
918
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
919
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
920
+ * 오래된 메시지도 함께 온다:
921
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
922
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
923
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
924
+ * 섞여 올 때만 탐색이 일어난다.
925
+ */
896
926
  private onIncoming;
897
927
  private onUpdated;
898
928
  private onDeleted;
@@ -946,9 +976,6 @@ declare class Room {
946
976
  private readonly cacheStore;
947
977
  private readonly cachePolicy;
948
978
  private readonly logger;
949
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
950
- private timerSeconds;
951
- private timerKnown;
952
979
  constructor(roomId: string, http: HttpClient, ws: ReconnectingWs, readDebounceMs: number, opts?: {
953
980
  cacheStore?: CacheStore;
954
981
  cachePolicy?: CachePolicy;
@@ -1150,6 +1177,8 @@ declare class Room {
1150
1177
  items: MessageOut[];
1151
1178
  nextCursor: MessageIdStr | null;
1152
1179
  hasMore: boolean;
1180
+ /** 히스토리 하한(unix ms). null=컷 없음 — 재입장 컷·대화 지우기가 수렴. */
1181
+ historyFrom: number | null;
1153
1182
  }>;
1154
1183
  /** 이 방의 상세 정보 (`GET /rooms/{id}`) — 이름·멤버 수·삭제 타이머 등. */
1155
1184
  detail(): Promise<RoomOut>;
@@ -1166,11 +1195,12 @@ declare class Room {
1166
1195
  items: MessageOut[];
1167
1196
  nextCursor: MessageIdStr | null;
1168
1197
  hasMore: boolean;
1198
+ historyFrom: number | null;
1169
1199
  } | null>;
1170
1200
  /**
1171
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1172
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1173
- * 살아남는 보안 충돌을 원천 차단한다.
1201
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1202
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1203
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1174
1204
  */
1175
1205
  private cacheWriteRecent;
1176
1206
  /**
@@ -1367,6 +1397,16 @@ declare class NoveraChat {
1367
1397
  private readDebounceMs;
1368
1398
  private logger;
1369
1399
  private connectedOnce;
1400
+ /**
1401
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
1402
+ * `?since=`와 backfill 시작점.
1403
+ *
1404
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
1405
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
1406
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
1407
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
1408
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
1409
+ */
1370
1410
  private lastMessageIdByRoom;
1371
1411
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
1372
1412
  * Used to drop the N-1 shared-room copies of a single online/offline
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,23 +778,71 @@ 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);
790
792
  this.hydratedCount = 0;
791
793
  }
792
- this._messages.unshift(...page.items.map(chatMessageFromHistory));
794
+ const indexById = /* @__PURE__ */ new Map();
795
+ this._messages.forEach((m, i) => indexById.set(m.id, i));
796
+ const fresh = [];
797
+ for (const item of page.items.map(chatMessageFromHistory)) {
798
+ const at = indexById.get(item.id);
799
+ if (at === void 0) fresh.push(item);
800
+ else this._messages[at] = item;
801
+ }
802
+ this._messages.unshift(...fresh);
793
803
  this.oldestCursor = page.nextCursor;
794
804
  this._hasMore = page.hasMore;
795
805
  this.emit("replaced");
796
806
  }
807
+ /**
808
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
809
+ *
810
+ * 판정 규칙(서버 계약, 07-23 확정):
811
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
812
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
813
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
814
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
815
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
816
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
817
+ *
818
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
819
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
820
+ */
821
+ mergeFresh(page) {
822
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
823
+ this._messages.splice(0, this.hydratedCount);
824
+ this.hydratedCount = 0;
825
+ const freshIds = new Set(page.items.map((m) => m.messageId));
826
+ const freshOldest = page.items.length > 0 ? page.items[0].messageId : null;
827
+ const hf = page.historyFrom ?? null;
828
+ const overlaps = cachedHead.length > 0 && freshOldest != null && freshIds.has(cachedHead.at(-1).id);
829
+ let preserved = [];
830
+ if (overlaps && page.hasMore) {
831
+ preserved = cachedHead.filter((m) => {
832
+ if (freshIds.has(m.id)) return false;
833
+ if (!idGreater(freshOldest, m.id)) return false;
834
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
835
+ return false;
836
+ }
837
+ return true;
838
+ });
839
+ }
840
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
841
+ this._messages.unshift(...preserved);
842
+ this.oldestCursor = preserved.length > 0 ? preserved[0].id : page.nextCursor;
843
+ this._hasMore = page.hasMore;
844
+ this.emit("replaced");
845
+ }
797
846
  // ── optimistic sends ────────────────────────────────────────────
798
847
  /**
799
848
  * Send `text` optimistically: a `sending` bubble appears immediately (one
@@ -921,8 +970,26 @@ var MessageCollection = class {
921
970
  this.bus.removeAll();
922
971
  }
923
972
  // ── event handlers ──────────────────────────────────────────────
973
+ /**
974
+ * 수신 프레임을 컬렉션에 넣는다.
975
+ *
976
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
977
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
978
+ * 오래된 메시지도 함께 온다:
979
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
980
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
981
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
982
+ * 섞여 올 때만 탐색이 일어난다.
983
+ */
924
984
  onIncoming(f) {
925
- this._messages.push(chatMessageFromLive(f));
985
+ const msg = chatMessageFromLive(f);
986
+ let i = this._messages.length - 1;
987
+ for (; i >= 0; i--) {
988
+ const cur = this._messages[i];
989
+ if (cur.id === msg.id) return;
990
+ if (!idGreater(cur.id, msg.id)) break;
991
+ }
992
+ this._messages.splice(i + 1, 0, msg);
926
993
  this.emit("inserted");
927
994
  }
928
995
  onUpdated(f) {
@@ -1106,9 +1173,6 @@ var Room = class _Room {
1106
1173
  cacheStore;
1107
1174
  cachePolicy;
1108
1175
  logger;
1109
- /** 삭제 타이머 여부 세션 메모 — null=미확인, 값=마지막 detail() 결과. */
1110
- timerSeconds = null;
1111
- timerKnown = false;
1112
1176
  /**
1113
1177
  * 이 방의 메시지 상태를 소유하는 `MessageCollection`을 만든다.
1114
1178
  *
@@ -1586,7 +1650,8 @@ var Room = class _Room {
1586
1650
  items: raw.items.map(toMessageOut),
1587
1651
  // 파싱 성공한 응답만 캐시에 넣는다
1588
1652
  nextCursor: raw.next_cursor,
1589
- hasMore: raw.has_more
1653
+ hasMore: raw.has_more,
1654
+ historyFrom: raw.history_from ?? null
1590
1655
  };
1591
1656
  void this.cacheWriteRecent(raw);
1592
1657
  return page;
@@ -1597,10 +1662,7 @@ var Room = class _Room {
1597
1662
  "GET",
1598
1663
  `/api/v1/rooms/${this.roomId}`
1599
1664
  );
1600
- const out = parseRoomOut(raw);
1601
- this.timerSeconds = out.timerSeconds;
1602
- this.timerKnown = true;
1603
- return out;
1665
+ return parseRoomOut(raw);
1604
1666
  }
1605
1667
  /**
1606
1668
  * 히스토리 최신 페이지의 캐시 스냅샷 — 마지막 `listRecent()` 성공 응답의
@@ -1622,7 +1684,8 @@ var Room = class _Room {
1622
1684
  return {
1623
1685
  items: j.items.map(toMessageOut),
1624
1686
  nextCursor: j.next_cursor,
1625
- hasMore: j.has_more
1687
+ hasMore: j.has_more,
1688
+ historyFrom: j.history_from ?? null
1626
1689
  };
1627
1690
  } catch (err) {
1628
1691
  this.logger?.("warn", `cache_read_failed key=${key}`, err);
@@ -1634,20 +1697,15 @@ var Room = class _Room {
1634
1697
  }
1635
1698
  }
1636
1699
  /**
1637
- * 최신 페이지 스냅샷 저장. 삭제 타이머가 켜진 방은 저장하지 않고 기존
1638
- * 스냅샷도 지운다 타이머 만료로 서버에서 사라진 메시지가 로컬 캐시에
1639
- * 살아남는 보안 충돌을 원천 차단한다.
1700
+ * 최신 페이지 스냅샷 저장. 삭제 타이머 방도 저장한다 서버가 만료를
1701
+ * 일반 `message_deleted` 프레임으로 브로드캐스트하고 히스토리에서도
1702
+ * 즉시 제외하므로, 만료분은 라이브 이벤트/`history_from` diff로 정리된다.
1640
1703
  */
1641
1704
  async cacheWriteRecent(raw) {
1642
1705
  const store = this.cacheStore;
1643
1706
  if (!store) return;
1644
1707
  const key = CacheKeys.msgs(this.roomId);
1645
1708
  try {
1646
- if (!this.timerKnown) await this.detail();
1647
- if (this.timerSeconds != null) {
1648
- await store.remove(key);
1649
- return;
1650
- }
1651
1709
  await store.write(key, JSON.stringify(raw));
1652
1710
  } catch (err) {
1653
1711
  this.logger?.("warn", `cache_write_failed key=${key}`, err);
@@ -2041,11 +2099,6 @@ var NoveraChat = class {
2041
2099
  if (!opts.appId) throw new Error("appId required");
2042
2100
  if (!opts.endpoint) throw new Error("endpoint required");
2043
2101
  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
2102
  this.readDebounceMs = opts.readWatermarkDebounceMs ?? 400;
2050
2103
  this.logger = opts.logger;
2051
2104
  if (opts.initialLastMessageIds) {
@@ -2101,6 +2154,16 @@ var NoveraChat = class {
2101
2154
  readDebounceMs;
2102
2155
  logger;
2103
2156
  connectedOnce = false;
2157
+ /**
2158
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
2159
+ * `?since=`와 backfill 시작점.
2160
+ *
2161
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
2162
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
2163
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
2164
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
2165
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
2166
+ */
2104
2167
  lastMessageIdByRoom = /* @__PURE__ */ new Map();
2105
2168
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
2106
2169
  * Used to drop the N-1 shared-room copies of a single online/offline
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.1",
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
@@ -57,6 +57,16 @@ export class NoveraChat {
57
57
  private readDebounceMs: number;
58
58
  private logger: ClientOptions["logger"];
59
59
  private connectedOnce = false;
60
+ /**
61
+ * 방별 "실시간 파이프라인으로 본 최대 message_id" — 재연결 핸드셰이크의
62
+ * `?since=`와 backfill 시작점.
63
+ *
64
+ * **의도적으로 낮게 유지한다.** 히스토리 로드(REST)는 이 값을 올리지
65
+ * 않는다 — 앵커가 낮으면 backfill이 이미 가진 것까지 다시 주는 쪽으로
66
+ * (안전), 높으면 못 받은 것을 영영 건너뛰는 쪽으로(치명) 실패하기
67
+ * 때문이다. 중복은 수신 측(`MessageCollection.onIncoming`)의 id dedupe가
68
+ * 흡수하므로, "빠뜨린 갱신"으로 보고 올리지 말 것.
69
+ */
60
70
  private lastMessageIdByRoom = new Map<string, MessageIdStr>();
61
71
  /** Most recently dispatched presence event key, `${userId}:${at}:${online}`.
62
72
  * Used to drop the N-1 shared-room copies of a single online/offline
@@ -68,11 +78,6 @@ export class NoveraChat {
68
78
  if (!opts.appId) throw new Error("appId required");
69
79
  if (!opts.endpoint) throw new Error("endpoint required");
70
80
  if (!opts.tokenProvider) throw new Error("tokenProvider required");
71
- if (opts.cachePolicy === "mergeDiff") {
72
- throw new Error(
73
- 'CachePolicy "mergeDiff" 는 아직 미구현 — 현재 "replaceByApi"만 지원',
74
- );
75
- }
76
81
 
77
82
  // 400ms is the read-receipt-UX sweet spot for chat apps. Shorter and
78
83
  // 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,28 +205,96 @@ 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); // 스냅샷 통째 폐기 → 서버가 이긴다
222
224
  this.hydratedCount = 0;
223
225
  }
224
- this._messages.unshift(...page.items.map(chatMessageFromHistory));
226
+ // id 기준 upsert — 이미 들고 있는 메시지는 서버 버전으로 교체하고, 없는
227
+ // 것만 앞에 잇는다. `load()`가 두 번 불려도(React StrictMode의 mount →
228
+ // cleanup → mount 사이클처럼) 같은 페이지가 겹쳐 쌓이지 않는다.
229
+ const indexById = new Map<string, number>();
230
+ this._messages.forEach((m, i) => indexById.set(m.id, i));
231
+ const fresh: ChatMessage[] = [];
232
+ for (const item of page.items.map(chatMessageFromHistory)) {
233
+ const at = indexById.get(item.id);
234
+ if (at === undefined) fresh.push(item);
235
+ else this._messages[at] = item; // 겹치는 구간은 서버가 이긴다
236
+ }
237
+ this._messages.unshift(...fresh);
225
238
  this.oldestCursor = page.nextCursor;
226
239
  this._hasMore = page.hasMore;
227
240
  this.emit("replaced");
228
241
  }
229
242
 
243
+ /**
244
+ * diff 병합 — 스냅샷의 옛 메시지를 보존해 fresh 페이지 아래에 잇는다.
245
+ *
246
+ * 판정 규칙(서버 계약, 07-23 확정):
247
+ * - **컷오프분**: `history_from`(히스토리 하한, unix ms)보다 오래된 캐시
248
+ * 메시지는 재입장 컷/대화 지우기로 볼 수 없게 된 것 → 폐기.
249
+ * - **삭제분**: fresh 커버 구간(id ≥ fresh 최저 id)에 있는데 fresh에
250
+ * 없는 캐시 메시지는 그 사이 삭제된 것(타이머 만료 포함) → 폐기.
251
+ * - **갭 폴백**: 캐시의 최신 메시지가 fresh와 겹치지 않으면 누락 구간이
252
+ * 있을 수 있으므로 병합하지 않고 통째 교체.
253
+ *
254
+ * 겹치는 구간은 항상 fresh가 정답(수정 반영) — 보존되는 건 fresh 범위
255
+ * **밖**의 옛 메시지뿐이라 "서버가 항상 이긴다" 불변식은 유지된다.
256
+ */
257
+ private mergeFresh(page: {
258
+ items: MessageOut[];
259
+ nextCursor: MessageIdStr | null;
260
+ hasMore: boolean;
261
+ historyFrom?: number | null;
262
+ }): void {
263
+ const cachedHead = this._messages.slice(0, this.hydratedCount);
264
+ this._messages.splice(0, this.hydratedCount);
265
+ this.hydratedCount = 0;
266
+
267
+ const freshIds = new Set(page.items.map((m) => m.messageId));
268
+ const freshOldest = page.items.length > 0 ? page.items[0]!.messageId : null;
269
+ const hf = page.historyFrom ?? null;
270
+
271
+ // 갭 폴백: 캐시 최신이 fresh에 없으면 연속성 보장 불가 → 통째 교체.
272
+ // fresh가 방 전체(hasMore=false)인데 범위 밖 캐시가 남는 경우도 유령이므로 교체.
273
+ const overlaps =
274
+ cachedHead.length > 0 &&
275
+ freshOldest != null &&
276
+ freshIds.has(cachedHead.at(-1)!.id);
277
+ let preserved: ChatMessage[] = [];
278
+ if (overlaps && page.hasMore) {
279
+ preserved = cachedHead.filter((m) => {
280
+ if (freshIds.has(m.id)) return false; // 겹침 → fresh가 대체
281
+ if (!idGreater(freshOldest, m.id)) return false; // fresh 구간 내 부재 → 삭제분
282
+ if (hf != null && m.createdAtMs != null && m.createdAtMs < hf) {
283
+ return false; // 컷오프분
284
+ }
285
+ return true;
286
+ });
287
+ }
288
+
289
+ this._messages.unshift(...page.items.map(chatMessageFromHistory));
290
+ this._messages.unshift(...preserved);
291
+ // 보존분이 있으면 스크롤백 커서를 보존분의 최저 id로 — 중복 로드 방지.
292
+ this.oldestCursor =
293
+ preserved.length > 0 ? preserved[0]!.id : page.nextCursor;
294
+ this._hasMore = page.hasMore;
295
+ this.emit("replaced");
296
+ }
297
+
230
298
  // ── optimistic sends ────────────────────────────────────────────
231
299
 
232
300
  /**
@@ -382,8 +450,26 @@ export class MessageCollection {
382
450
 
383
451
  // ── event handlers ──────────────────────────────────────────────
384
452
 
453
+ /**
454
+ * 수신 프레임을 컬렉션에 넣는다.
455
+ *
456
+ * 두 가지를 방어한다 — backfill(`chat.backfill()`)은 재연결 시 앵커
457
+ * 이후를 **다시** 실어오므로, 이미 들고 있는 메시지가 섞여 오고 화면보다
458
+ * 오래된 메시지도 함께 온다:
459
+ * - **중복**: 같은 id가 이미 있으면 무시한다.
460
+ * - **순서**: 뒤에서부터 자리를 찾아 꽂는다. 실시간 프레임은 항상 마지막
461
+ * 메시지보다 새로우므로 비교 1번으로 끝나고(빠른 경로), 옛 메시지가
462
+ * 섞여 올 때만 탐색이 일어난다.
463
+ */
385
464
  private onIncoming(f: WsChatReceive): void {
386
- this._messages.push(chatMessageFromLive(f));
465
+ const msg = chatMessageFromLive(f);
466
+ let i = this._messages.length - 1;
467
+ for (; i >= 0; i--) {
468
+ const cur = this._messages[i]!;
469
+ if (cur.id === msg.id) return; // 이미 있음 — 재전송분
470
+ if (!idGreater(cur.id, msg.id)) break; // 여기가 삽입 자리
471
+ }
472
+ this._messages.splice(i + 1, 0, msg);
387
473
  this.emit("inserted");
388
474
  }
389
475
 
@@ -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