@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.41
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/README.md +41 -1
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +78 -8
- package/lib/runtime/chatSession.js +504 -39
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +4 -5
- package/lib/runtime/createChat.js +14 -17
- package/lib/runtime/durableSender.d.ts +30 -4
- package/lib/runtime/durableSender.js +174 -34
- package/lib/runtime/index.d.ts +4 -0
- package/lib/runtime/index.js +4 -0
- package/lib/runtime/messageClient.d.ts +33 -6
- package/lib/runtime/messageClient.js +443 -71
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +42 -0
- package/lib/runtime/messageLaunch.js +127 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +47 -2
- package/lib/runtime/messageStore.js +233 -10
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +44 -6
- package/lib/runtime/readIndex.js +238 -11
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +21 -8
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +121 -18
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/package.json +6 -1
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
2
|
+
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
3
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
1
4
|
let subscriptionSequence = 0;
|
|
2
5
|
function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
|
|
3
6
|
/** One active conversation; fixed event window, bounded batches, no count polling. */
|
|
@@ -11,29 +14,58 @@ export class ReadCountsClient {
|
|
|
11
14
|
generation = 0;
|
|
12
15
|
retries = 0;
|
|
13
16
|
closed = false;
|
|
17
|
+
closing;
|
|
14
18
|
revision = -1;
|
|
15
19
|
running = false;
|
|
16
20
|
dirty = false;
|
|
17
21
|
subscribed = false;
|
|
18
22
|
subscriptionDenied = false;
|
|
19
23
|
subscriptionPending;
|
|
24
|
+
cache;
|
|
25
|
+
lastResult;
|
|
26
|
+
publish(value) {
|
|
27
|
+
if (JSON.stringify(value) === JSON.stringify(this.lastResult))
|
|
28
|
+
return;
|
|
29
|
+
this.lastResult = value;
|
|
30
|
+
this.options.onChange(value);
|
|
31
|
+
}
|
|
32
|
+
revoked = false;
|
|
33
|
+
accessVersion = 0;
|
|
34
|
+
requestedRevision = -1;
|
|
35
|
+
force = false;
|
|
20
36
|
constructor(options) {
|
|
21
37
|
this.options = options;
|
|
38
|
+
if (options.scope) {
|
|
39
|
+
if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
|
|
40
|
+
throw new Error("READ_COUNTS_SCOPE_MISMATCH");
|
|
41
|
+
this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
denyCache() {
|
|
45
|
+
this.revoked = true;
|
|
46
|
+
this.accessVersion++;
|
|
47
|
+
this.lastResult = undefined;
|
|
48
|
+
void this.cache?.invalidate().catch(error => this.options.onError?.(error));
|
|
22
49
|
}
|
|
23
50
|
async post(path, body, signal) {
|
|
51
|
+
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
24
52
|
const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
|
|
25
53
|
if (!response.ok)
|
|
26
54
|
throw new Error(`READ_COUNTS_HTTP_${response.status}`);
|
|
27
55
|
const envelope = await response.json();
|
|
28
56
|
if (envelope.success !== true)
|
|
29
57
|
throw new Error("INVALID_READ_COUNTS_RESPONSE");
|
|
58
|
+
if (this.options.access)
|
|
59
|
+
await assertResponseCacheAccess(this.options.access, scopeVersion);
|
|
30
60
|
return envelope.data;
|
|
31
61
|
}
|
|
32
62
|
subscription(threadId, release) {
|
|
63
|
+
if (release && isOffline())
|
|
64
|
+
return Promise.resolve();
|
|
33
65
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
34
66
|
}
|
|
35
67
|
renew() {
|
|
36
|
-
if (!this.thread || this.closed || this.subscriptionDenied)
|
|
68
|
+
if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
|
|
37
69
|
return;
|
|
38
70
|
const threadId = this.thread, generation = this.generation;
|
|
39
71
|
if (this.subscriptionPending?.generation === generation)
|
|
@@ -48,18 +80,29 @@ export class ReadCountsClient {
|
|
|
48
80
|
this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
|
|
49
81
|
this.renew(); }, 20_000);
|
|
50
82
|
// Recover changes that occurred before a receiver/subscription became ready.
|
|
51
|
-
if (recovered
|
|
83
|
+
if (recovered) {
|
|
84
|
+
// A fresh cache predates the new lease and cannot cover its delivery gap.
|
|
85
|
+
this.force = true;
|
|
52
86
|
this.schedule(0);
|
|
87
|
+
}
|
|
53
88
|
}).catch(error => {
|
|
54
89
|
if (this.closed || generation !== this.generation)
|
|
55
90
|
return;
|
|
56
91
|
this.subscribed = false;
|
|
57
92
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
58
93
|
this.subscriptionDenied = true;
|
|
94
|
+
this.denyCache();
|
|
59
95
|
clearInterval(this.heartbeat);
|
|
60
96
|
this.heartbeat = undefined;
|
|
61
97
|
}
|
|
62
|
-
|
|
98
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
99
|
+
this.lastResult = undefined;
|
|
100
|
+
this.subscriptionDenied = true;
|
|
101
|
+
clearInterval(this.heartbeat);
|
|
102
|
+
this.heartbeat = undefined;
|
|
103
|
+
}
|
|
104
|
+
if (!isOfflineNetworkFailure(error))
|
|
105
|
+
this.options.onError?.(error);
|
|
63
106
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
64
107
|
this.subscriptionPending = undefined; });
|
|
65
108
|
this.subscriptionPending = pending;
|
|
@@ -83,9 +126,12 @@ export class ReadCountsClient {
|
|
|
83
126
|
clearTimeout(this.timer);
|
|
84
127
|
clearInterval(this.heartbeat);
|
|
85
128
|
this.timer = undefined;
|
|
129
|
+
this.lastResult = undefined;
|
|
86
130
|
this.thread = threadId;
|
|
87
131
|
this.ids = ids;
|
|
88
132
|
this.revision = -1;
|
|
133
|
+
this.requestedRevision = -1;
|
|
134
|
+
this.force = false;
|
|
89
135
|
this.dirty = false;
|
|
90
136
|
this.retries = 0;
|
|
91
137
|
this.subscribed = false;
|
|
@@ -104,11 +150,13 @@ export class ReadCountsClient {
|
|
|
104
150
|
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
105
151
|
if (threadId !== this.thread || revision <= this.revision || this.closed)
|
|
106
152
|
return;
|
|
153
|
+
this.requestedRevision = Math.max(this.requestedRevision, revision);
|
|
107
154
|
this.schedule(3000);
|
|
108
155
|
}
|
|
109
156
|
reconnect() {
|
|
110
157
|
if (!this.thread || this.closed)
|
|
111
158
|
return;
|
|
159
|
+
this.force = true;
|
|
112
160
|
this.subscribed = false;
|
|
113
161
|
this.subscriptionDenied = false;
|
|
114
162
|
this.renew();
|
|
@@ -137,22 +185,60 @@ export class ReadCountsClient {
|
|
|
137
185
|
let retryDelay;
|
|
138
186
|
const controller = new AbortController();
|
|
139
187
|
this.controller = controller;
|
|
188
|
+
const accessVersion = this.accessVersion, force = this.force;
|
|
189
|
+
this.force = false;
|
|
190
|
+
const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
|
|
140
191
|
try {
|
|
192
|
+
const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
193
|
+
throw error; this.options.onError?.(error); return undefined; }) : undefined;
|
|
194
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
195
|
+
return;
|
|
196
|
+
const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
|
|
197
|
+
if (cached) {
|
|
198
|
+
this.revision = cached.value.revision;
|
|
199
|
+
this.publish(cached.value);
|
|
200
|
+
const offline = typeof navigator !== "undefined" && navigator.onLine === false;
|
|
201
|
+
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (isOffline())
|
|
205
|
+
return;
|
|
141
206
|
const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
|
|
142
|
-
if (generation !== this.generation || this.closed)
|
|
207
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
143
208
|
return;
|
|
144
209
|
if (value?.appId !== this.options.appId || value.threadId !== threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !value.counts || typeof value.counts !== "object" || ids.some(id => !Number.isSafeInteger(value.counts[id]) || value.counts[id] < 0) || Object.keys(value.counts).some(id => !ids.includes(id)))
|
|
145
210
|
throw new Error("INVALID_READ_COUNTS_RESULT");
|
|
211
|
+
if (value.revision < this.revision)
|
|
212
|
+
return;
|
|
146
213
|
this.retries = 0;
|
|
214
|
+
this.revoked = false;
|
|
215
|
+
if (stored)
|
|
216
|
+
await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
|
|
217
|
+
throw error; this.options.onError?.(error); });
|
|
218
|
+
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
219
|
+
return;
|
|
147
220
|
this.revision = value.revision;
|
|
148
|
-
this.
|
|
221
|
+
this.publish(value);
|
|
149
222
|
if (!this.subscribed)
|
|
150
223
|
this.renew();
|
|
151
224
|
}
|
|
152
225
|
catch (error) {
|
|
226
|
+
if (isOfflineNetworkFailure(error)) {
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
153
230
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
231
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
232
|
+
this.denyCache();
|
|
233
|
+
if (isResponseCacheAuthorityError(error)) {
|
|
234
|
+
this.lastResult = undefined;
|
|
235
|
+
this.subscriptionDenied = true;
|
|
236
|
+
this.dirty = false;
|
|
237
|
+
clearInterval(this.heartbeat);
|
|
238
|
+
this.heartbeat = undefined;
|
|
239
|
+
}
|
|
154
240
|
this.options.onError?.(error);
|
|
155
|
-
if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
241
|
+
if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
|
|
156
242
|
retryDelay = 1000 * 2 ** this.retries++;
|
|
157
243
|
this.dirty = true;
|
|
158
244
|
}
|
|
@@ -166,9 +252,10 @@ export class ReadCountsClient {
|
|
|
166
252
|
}
|
|
167
253
|
}
|
|
168
254
|
}
|
|
255
|
+
retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
|
|
169
256
|
close() {
|
|
170
|
-
if (this.
|
|
171
|
-
return;
|
|
257
|
+
if (this.closing)
|
|
258
|
+
return this.closing;
|
|
172
259
|
const old = this.thread;
|
|
173
260
|
this.closed = true;
|
|
174
261
|
this.generation++;
|
|
@@ -178,5 +265,6 @@ export class ReadCountsClient {
|
|
|
178
265
|
if (old)
|
|
179
266
|
void this.subscription(old, true).catch(() => { });
|
|
180
267
|
this.thread = undefined;
|
|
268
|
+
return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
|
|
181
269
|
}
|
|
182
270
|
}
|
|
@@ -7,9 +7,16 @@ export type IndexedReadMessage = {
|
|
|
7
7
|
mentions: string[];
|
|
8
8
|
readPolicy: "visible" | "explicit";
|
|
9
9
|
recalled: boolean;
|
|
10
|
-
/**
|
|
10
|
+
/** Legacy standalone readers may retain payloads; unified sessions use the message store. */
|
|
11
11
|
payload: unknown;
|
|
12
|
+
/** Body-free control classification retained by metadata-only reading indexes. */
|
|
13
|
+
readingControl?: boolean;
|
|
12
14
|
};
|
|
15
|
+
/** Retain read semantics without a second durable copy of message bodies. */
|
|
16
|
+
export declare function readingMetadata<T extends {
|
|
17
|
+
appId: string;
|
|
18
|
+
threads: IndexedReadThread[];
|
|
19
|
+
}>(state: T): T;
|
|
13
20
|
export type ReadingOperation = {
|
|
14
21
|
operationId: string;
|
|
15
22
|
threadId: string;
|
|
@@ -72,32 +79,63 @@ export declare function coalesceReadOperations(current: ReadIndexState): (Readin
|
|
|
72
79
|
operationIds: string[];
|
|
73
80
|
})[];
|
|
74
81
|
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
75
|
-
export declare function unreadInThread(state: ReadIndexState, threadId: string): {
|
|
82
|
+
export declare function unreadInThread(state: ReadIndexState, threadId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>): {
|
|
76
83
|
unreadMessageCount: number;
|
|
77
84
|
unreadMentionCount: number;
|
|
78
85
|
badgeCount: number;
|
|
79
86
|
};
|
|
87
|
+
export type ReadIndexVersions = Readonly<Record<string, number>>;
|
|
88
|
+
export type ReadIndexTransform = (current: ReadIndexState, versions?: ReadIndexVersions) => ReadIndexState;
|
|
89
|
+
export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions, teamId?: string): void;
|
|
90
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
91
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
92
|
+
export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
|
|
80
93
|
export interface ReadIndexPersistence {
|
|
81
94
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
|
+
loadVersioned?(appId: string, profileId: string): Promise<{
|
|
96
|
+
state: ReadIndexState | undefined;
|
|
97
|
+
versions: ReadIndexVersions;
|
|
98
|
+
}>;
|
|
99
|
+
loadVersions?(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
100
|
+
invalidateTeam?(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
82
101
|
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
83
|
-
update(appId: string, profileId: string, transform:
|
|
102
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
84
103
|
}
|
|
85
104
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
86
105
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
87
106
|
private storageScope?;
|
|
107
|
+
private migrateLegacy;
|
|
108
|
+
private metadataOnly;
|
|
88
109
|
private database;
|
|
89
110
|
private profiles;
|
|
111
|
+
private threadRows;
|
|
112
|
+
private messageRows;
|
|
113
|
+
private positionRows;
|
|
114
|
+
private normalizedStates;
|
|
115
|
+
private versions;
|
|
90
116
|
private closed;
|
|
91
|
-
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined);
|
|
117
|
+
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
|
|
92
118
|
private transaction;
|
|
93
119
|
private key;
|
|
120
|
+
private hydrate;
|
|
121
|
+
private loadCurrent;
|
|
122
|
+
private loadStored;
|
|
123
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
124
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
125
|
+
private persist;
|
|
94
126
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
|
-
|
|
127
|
+
loadVersioned(appId: string, profileId: string): Promise<{
|
|
128
|
+
state: ReadIndexState | undefined;
|
|
129
|
+
versions: Record<string, number>;
|
|
130
|
+
}>;
|
|
131
|
+
loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
132
|
+
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
133
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
96
134
|
close(): Promise<void>;
|
|
97
135
|
}
|
|
98
136
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
|
99
137
|
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
100
|
-
export declare function unreadInConversation(state: ReadIndexState, conversationId: string): {
|
|
138
|
+
export declare function unreadInConversation(state: ReadIndexState, conversationId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>): {
|
|
101
139
|
unreadMessageCount: number;
|
|
102
140
|
unreadMentionCount: number;
|
|
103
141
|
badgeCount: number;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
3
|
import { isValidChatTeamId } from "./thread.js";
|
|
4
|
+
/** Retain read semantics without a second durable copy of message bodies. */
|
|
5
|
+
export function readingMetadata(state) {
|
|
6
|
+
let changed = false;
|
|
7
|
+
const threads = state.threads.map(thread => {
|
|
8
|
+
let different = false;
|
|
9
|
+
const messages = thread.messages.map(message => {
|
|
10
|
+
if (message.payload === null && typeof message.readingControl === "boolean")
|
|
11
|
+
return message;
|
|
12
|
+
different = true;
|
|
13
|
+
return { ...message, payload: null, readingControl: Boolean(message.readingControl) || isReadingControl(message.payload, state.appId) };
|
|
14
|
+
});
|
|
15
|
+
if (!different)
|
|
16
|
+
return thread;
|
|
17
|
+
changed = true;
|
|
18
|
+
return { ...thread, messages };
|
|
19
|
+
});
|
|
20
|
+
return changed ? { ...state, threads } : state;
|
|
21
|
+
}
|
|
3
22
|
export class ReadSyncGap extends Error {
|
|
4
23
|
cursor;
|
|
5
24
|
constructor(cursor) {
|
|
@@ -153,31 +172,66 @@ export function coalesceReadOperations(current) {
|
|
|
153
172
|
return [...batches.values()];
|
|
154
173
|
}
|
|
155
174
|
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
156
|
-
export function unreadInThread(state, threadId) {
|
|
175
|
+
export function unreadInThread(state, threadId, hidden) {
|
|
157
176
|
const thread = state.threads.find(t => t.id === threadId);
|
|
158
177
|
if (!thread)
|
|
159
178
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
160
179
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
161
|
-
const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
180
|
+
const messages = thread.messages.filter(message => !message.recalled && !hidden?.get(message.id)?.has(message.from) && !(message.readingControl || isReadingControl(message.payload, state.appId)) && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
181
|
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
|
|
163
182
|
const unreadMessageCount = messages.length;
|
|
164
183
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
165
184
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
166
185
|
}
|
|
186
|
+
function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
|
|
187
|
+
export function assertReadIndexVersions(expected, current = {}, teamId) {
|
|
188
|
+
const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
|
|
189
|
+
if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
|
|
190
|
+
throw new Error("READ_SCOPE_CHANGED");
|
|
191
|
+
}
|
|
192
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
193
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
194
|
+
export function removeReadTeam(state, teamId, knownThreadIds = []) {
|
|
195
|
+
const removed = new Set(knownThreadIds);
|
|
196
|
+
for (const thread of state.threads)
|
|
197
|
+
if (thread.teamId === teamId) {
|
|
198
|
+
removed.add(thread.id);
|
|
199
|
+
if (thread.conversationId)
|
|
200
|
+
removed.add(thread.conversationId);
|
|
201
|
+
}
|
|
202
|
+
return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
|
|
203
|
+
pending: state.pending.filter(operation => !removed.has(operation.threadId)),
|
|
204
|
+
observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
|
|
205
|
+
}
|
|
167
206
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
168
207
|
export class IndexedDbReadIndex {
|
|
169
208
|
storageScope;
|
|
209
|
+
migrateLegacy;
|
|
210
|
+
metadataOnly;
|
|
170
211
|
database;
|
|
171
212
|
profiles;
|
|
213
|
+
threadRows;
|
|
214
|
+
messageRows;
|
|
215
|
+
positionRows;
|
|
216
|
+
normalizedStates = new WeakSet();
|
|
217
|
+
versions;
|
|
172
218
|
closed = false;
|
|
173
|
-
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
|
|
219
|
+
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
|
|
174
220
|
this.storageScope = storageScope;
|
|
221
|
+
this.migrateLegacy = migrateLegacy;
|
|
222
|
+
this.metadataOnly = metadataOnly;
|
|
175
223
|
if (!factory)
|
|
176
224
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
177
225
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
178
226
|
// The empty schema retains the legacy out-of-line primary key and records.
|
|
179
227
|
this.database.version(1).stores({ profiles: "" });
|
|
228
|
+
this.database.version(2).stores({ profiles: "", versions: "" });
|
|
229
|
+
this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
|
|
230
|
+
this.positionRows = this.database.table("readPositions");
|
|
231
|
+
this.threadRows = this.database.table("readThreads");
|
|
232
|
+
this.messageRows = this.database.table("readMessages");
|
|
180
233
|
this.profiles = this.database.table("profiles");
|
|
234
|
+
this.versions = this.database.table("versions");
|
|
181
235
|
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
182
236
|
}
|
|
183
237
|
async transaction(mode, execute) {
|
|
@@ -186,7 +240,7 @@ export class IndexedDbReadIndex {
|
|
|
186
240
|
throw new Error("READ_STORAGE_CLOSED");
|
|
187
241
|
let started = false;
|
|
188
242
|
try {
|
|
189
|
-
return await this.database.transaction(mode, this.profiles, async () => {
|
|
243
|
+
return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
|
|
190
244
|
if (this.closed)
|
|
191
245
|
throw new Error("READ_STORAGE_CLOSED");
|
|
192
246
|
started = true;
|
|
@@ -205,17 +259,190 @@ export class IndexedDbReadIndex {
|
|
|
205
259
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
206
260
|
}
|
|
207
261
|
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
262
|
+
async hydrate(current, key) {
|
|
263
|
+
if (current.normalized) {
|
|
264
|
+
const owner = JSON.stringify(key);
|
|
265
|
+
const [threads, messages, positions] = await Promise.all([
|
|
266
|
+
this.threadRows.where("owner").equals(owner).toArray(),
|
|
267
|
+
this.messageRows.where("owner").equals(owner).toArray(),
|
|
268
|
+
this.positionRows.where("owner").equals(owner).toArray(),
|
|
269
|
+
]);
|
|
270
|
+
const byThread = new Map();
|
|
271
|
+
for (const row of messages) {
|
|
272
|
+
if (!byThread.has(row.thread))
|
|
273
|
+
byThread.set(row.thread, new Map());
|
|
274
|
+
byThread.get(row.thread).set(row.id, row.value);
|
|
275
|
+
}
|
|
276
|
+
const byPosition = new Map(positions.map(row => [row.id, row]));
|
|
277
|
+
const byId = new Map(threads.map(row => [row.id, row]));
|
|
278
|
+
const { normalized: _normalized, threadOrder, ...state } = current;
|
|
279
|
+
const result = { ...state, threads: (threadOrder ?? []).map(id => {
|
|
280
|
+
const row = byId.get(id);
|
|
281
|
+
const position = byPosition.get(id);
|
|
282
|
+
if (!row || !position)
|
|
283
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
284
|
+
return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
|
|
285
|
+
const message = byThread.get(id)?.get(messageId);
|
|
286
|
+
if (!message)
|
|
287
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
288
|
+
return message;
|
|
289
|
+
}) };
|
|
290
|
+
}) };
|
|
291
|
+
this.normalizedStates.add(result);
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
return current;
|
|
295
|
+
}
|
|
296
|
+
async loadCurrent(appId, profileId) {
|
|
297
|
+
const current = await this.loadStored(appId, profileId);
|
|
298
|
+
if (!current || !this.metadataOnly)
|
|
299
|
+
return current;
|
|
300
|
+
const metadata = readingMetadata(current);
|
|
301
|
+
if (metadata !== current)
|
|
302
|
+
await this.persist(appId, profileId, current, metadata);
|
|
303
|
+
return metadata;
|
|
304
|
+
}
|
|
305
|
+
async loadStored(appId, profileId) {
|
|
306
|
+
const current = await this.profiles.get(this.key(appId, profileId));
|
|
307
|
+
if (current?.normalized)
|
|
308
|
+
return this.hydrate(current, this.key(appId, profileId));
|
|
309
|
+
if (current) {
|
|
310
|
+
const next = this.metadataOnly ? readingMetadata(current) : current;
|
|
311
|
+
if (this.migrateLegacy || this.metadataOnly)
|
|
312
|
+
await this.persist(appId, profileId, undefined, next, true);
|
|
313
|
+
return next;
|
|
314
|
+
}
|
|
315
|
+
if (!this.migrateLegacy || this.storageScope === undefined)
|
|
316
|
+
return undefined;
|
|
317
|
+
const markerKey = [appId, profileId, "legacy-owner-v1"];
|
|
318
|
+
const marker = await this.profiles.get(markerKey);
|
|
319
|
+
if (marker && marker.legacyOwner !== this.storageScope)
|
|
320
|
+
return undefined;
|
|
321
|
+
const legacyRow = await this.profiles.get([appId, profileId]);
|
|
322
|
+
if (!legacyRow)
|
|
323
|
+
return undefined;
|
|
324
|
+
const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
|
|
325
|
+
const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
|
|
326
|
+
if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
|
|
327
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
328
|
+
const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
|
|
329
|
+
await this.profiles.put(ownership, markerKey);
|
|
330
|
+
await this.persist(appId, profileId, undefined, legacy);
|
|
331
|
+
await this.profiles.delete([appId, profileId]);
|
|
332
|
+
const oldOwner = JSON.stringify([appId, profileId]);
|
|
333
|
+
await this.threadRows.where("owner").equals(oldOwner).delete();
|
|
334
|
+
await this.messageRows.where("owner").equals(oldOwner).delete();
|
|
335
|
+
await this.positionRows.where("owner").equals(oldOwner).delete();
|
|
336
|
+
return legacy;
|
|
337
|
+
}
|
|
338
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
339
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
340
|
+
async persist(appId, profileId, previous, next, replaceLegacy = false) {
|
|
341
|
+
if (previous && !this.normalizedStates.has(previous)) {
|
|
342
|
+
previous = undefined;
|
|
343
|
+
replaceLegacy = true;
|
|
344
|
+
}
|
|
345
|
+
const owner = JSON.stringify(this.key(appId, profileId));
|
|
346
|
+
const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
|
|
347
|
+
const nextIds = new Set(next.threads.map(thread => thread.id));
|
|
348
|
+
if (nextIds.size !== next.threads.length)
|
|
349
|
+
throw new Error("DUPLICATE_READ_THREAD");
|
|
350
|
+
const threadWrites = [], messageWrites = [], positionWrites = [];
|
|
351
|
+
const messageDeletes = [];
|
|
352
|
+
for (const thread of next.threads) {
|
|
353
|
+
const old = oldThreads.get(thread.id);
|
|
354
|
+
if (old === thread)
|
|
355
|
+
continue;
|
|
356
|
+
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
357
|
+
const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
|
|
358
|
+
if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
359
|
+
positionWrites.push(position);
|
|
360
|
+
const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
|
|
361
|
+
if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
|
|
362
|
+
const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
|
|
363
|
+
const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
|
|
364
|
+
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
365
|
+
threadWrites.push(metadata);
|
|
366
|
+
}
|
|
367
|
+
if (old?.messages === messages)
|
|
368
|
+
continue;
|
|
369
|
+
const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
|
|
370
|
+
const ids = new Set();
|
|
371
|
+
for (const message of messages) {
|
|
372
|
+
if (ids.has(message.id))
|
|
373
|
+
throw new Error("INVALID_READ_MESSAGE");
|
|
374
|
+
ids.add(message.id);
|
|
375
|
+
const prior = byId.get(message.id);
|
|
376
|
+
if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
|
|
377
|
+
messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
|
|
378
|
+
}
|
|
379
|
+
for (const id of byId.keys())
|
|
380
|
+
if (!ids.has(id))
|
|
381
|
+
messageDeletes.push([owner, thread.id, id]);
|
|
382
|
+
}
|
|
383
|
+
for (const id of oldThreads.keys())
|
|
384
|
+
if (!nextIds.has(id)) {
|
|
385
|
+
await this.threadRows.delete([owner, id]);
|
|
386
|
+
await this.positionRows.delete([owner, id]);
|
|
387
|
+
await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
|
|
388
|
+
}
|
|
389
|
+
// A legacy document can replace the full profile after normalization. Its
|
|
390
|
+
// replacement is authoritative for the row set; discard unreachable bodies
|
|
391
|
+
// in the same transaction before installing the rebuilt normalized records.
|
|
392
|
+
if (replaceLegacy) {
|
|
393
|
+
await this.messageRows.where("owner").equals(owner).delete();
|
|
394
|
+
await this.threadRows.where("owner").equals(owner).delete();
|
|
395
|
+
await this.positionRows.where("owner").equals(owner).delete();
|
|
396
|
+
}
|
|
397
|
+
// Bound each IndexedDB bulk request. A snapshot and its cursor still commit
|
|
398
|
+
// atomically; bounding total snapshot transaction bytes is separate work.
|
|
399
|
+
for (let offset = 0; offset < positionWrites.length; offset += 128)
|
|
400
|
+
await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
|
|
401
|
+
for (let offset = 0; offset < threadWrites.length; offset += 128)
|
|
402
|
+
await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
|
|
403
|
+
for (let offset = 0; offset < messageWrites.length; offset += 128)
|
|
404
|
+
await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
|
|
405
|
+
for (let offset = 0; offset < messageDeletes.length; offset += 128)
|
|
406
|
+
await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
|
|
407
|
+
const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
|
|
408
|
+
const nextHeader = header(next);
|
|
409
|
+
if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
|
|
410
|
+
await this.profiles.put(nextHeader, this.key(appId, profileId));
|
|
411
|
+
this.normalizedStates.add(next);
|
|
412
|
+
}
|
|
208
413
|
async load(appId, profileId) {
|
|
209
|
-
return this.transaction("r", () => this.
|
|
414
|
+
return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", () => this.loadCurrent(appId, profileId));
|
|
415
|
+
}
|
|
416
|
+
async loadVersioned(appId, profileId) {
|
|
417
|
+
return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", async () => ({
|
|
418
|
+
state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
async loadVersions(appId, profileId) {
|
|
422
|
+
return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
423
|
+
}
|
|
424
|
+
async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
|
|
425
|
+
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
|
|
426
|
+
throw new Error("INVALID_READ_SCOPE_VERSION");
|
|
427
|
+
return this.transaction("rw", async () => {
|
|
428
|
+
const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
|
|
429
|
+
const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
|
|
430
|
+
if (readTeamVersion(teams, teamId) >= version)
|
|
431
|
+
return state;
|
|
432
|
+
const next = removeReadTeam(state, teamId, knownThreadIds);
|
|
433
|
+
await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
|
|
434
|
+
await this.persist(appId, profileId, state, next);
|
|
435
|
+
return next;
|
|
436
|
+
});
|
|
210
437
|
}
|
|
211
438
|
async update(appId, profileId, transform) {
|
|
212
439
|
return this.transaction("rw", async () => {
|
|
213
|
-
const previous = await this.
|
|
214
|
-
const
|
|
440
|
+
const previous = await this.loadCurrent(appId, profileId);
|
|
441
|
+
const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
442
|
+
const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
|
|
215
443
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
216
444
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
217
|
-
|
|
218
|
-
await this.profiles.put(next, this.key(appId, profileId));
|
|
445
|
+
await this.persist(appId, profileId, previous, next);
|
|
219
446
|
return next;
|
|
220
447
|
});
|
|
221
448
|
}
|
|
@@ -231,14 +458,14 @@ function readSource(state, conversationId, messageId) {
|
|
|
231
458
|
return sources[0];
|
|
232
459
|
}
|
|
233
460
|
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
234
|
-
export function unreadInConversation(state, conversationId) {
|
|
461
|
+
export function unreadInConversation(state, conversationId, hidden) {
|
|
235
462
|
const sources = sourcesInConversation(state, conversationId);
|
|
236
463
|
if (!sources.length)
|
|
237
464
|
throw new Error("UNKNOWN_READ_CONVERSATION");
|
|
238
465
|
if (new Set(sources.map(source => source.teamId)).size > 1)
|
|
239
466
|
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
240
467
|
return sources.reduce((total, source) => {
|
|
241
|
-
const unread = unreadInThread(state, source.id);
|
|
468
|
+
const unread = unreadInThread(state, source.id, hidden);
|
|
242
469
|
return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
|
|
243
470
|
}, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
|
|
244
471
|
}
|