@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 +54 -27
- package/dist/index.d.cts +36 -17
- package/dist/index.d.ts +36 -17
- package/dist/index.js +54 -27
- package/package.json +1 -1
- package/src/cache.ts +7 -5
- package/src/client.ts +0 -5
- package/src/features/message-collection.ts +64 -7
- package/src/features/room.ts +11 -17
- package/src/generated/rest.ts +64 -0
- package/src/types.ts +2 -1
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 최신 페이지를 컬렉션에
|
|
816
|
-
* replaceByApi(
|
|
817
|
-
*
|
|
816
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
817
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
818
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
818
819
|
*/
|
|
819
820
|
applyFresh(page) {
|
|
820
|
-
if (this.policy === "mergeDiff") {
|
|
821
|
-
|
|
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
|
-
|
|
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 도착 시 통째 교체(
|
|
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
|
-
*
|
|
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 응답의 합성 방식.
|
|
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
|
-
* 코어가 강제한다.
|
|
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 최신 페이지를 컬렉션에
|
|
858
|
-
* replaceByApi(
|
|
859
|
-
*
|
|
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 도착 시 통째 교체(
|
|
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
|
-
*
|
|
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 응답의 합성 방식.
|
|
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
|
-
* 코어가 강제한다.
|
|
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 최신 페이지를 컬렉션에
|
|
858
|
-
* replaceByApi(
|
|
859
|
-
*
|
|
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 최신 페이지를 컬렉션에
|
|
781
|
-
* replaceByApi(
|
|
782
|
-
*
|
|
781
|
+
* API 최신 페이지를 컬렉션에 반영한다.
|
|
782
|
+
* replaceByApi(기본)는 스냅샷을 걷어내고 통째 교체하고, mergeDiff는
|
|
783
|
+
* 스냅샷의 옛 메시지를 보존·병합한다(`mergeFresh`).
|
|
783
784
|
*/
|
|
784
785
|
applyFresh(page) {
|
|
785
|
-
if (this.policy === "mergeDiff") {
|
|
786
|
-
|
|
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
|
-
|
|
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
package/src/cache.ts
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
*
|
|
5
5
|
* 원칙:
|
|
6
6
|
* 1. 캐시 = 렌더 힌트, 서버가 항상 이긴다 — 시작 시 스냅샷 즉시 렌더,
|
|
7
|
-
* API 도착 시 통째 교체(
|
|
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
|
-
*
|
|
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
|
-
* 코어가 강제한다.
|
|
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 최신 페이지를 컬렉션에
|
|
209
|
-
* replaceByApi(
|
|
210
|
-
*
|
|
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
|
-
|
|
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
|
/**
|
package/src/features/room.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/generated/rest.ts
CHANGED
|
@@ -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 응답의 합성 방식.
|
|
254
|
+
/** 캐시 스냅샷과 API 응답의 합성 방식. 기본 `"replaceByApi"`(통째 교체),
|
|
255
|
+
* `"mergeDiff"`는 옵트인 — 옛 캐시 메시지를 보존·병합. */
|
|
255
256
|
cachePolicy?: CachePolicy;
|
|
256
257
|
autoReconnect?: boolean; // default true
|
|
257
258
|
pingIntervalMs?: number; // default 20_000
|