@pmate/chat-sdk 0.12.0-next.23 → 0.12.0-next.25
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 +15 -0
- package/lib/runtime/chatSession.d.ts +8 -0
- package/lib/runtime/chatSession.js +72 -17
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/durableSender.js +1 -0
- package/lib/runtime/messageClient.js +12 -5
- package/lib/runtime/messageReaders.js +3 -0
- package/lib/runtime/readCounts.js +11 -1
- package/lib/runtime/readIndex.d.ts +1 -1
- package/lib/runtime/readIndex.js +2 -2
- package/lib/runtime/readingClient.d.ts +4 -0
- package/lib/runtime/readingClient.js +44 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -228,3 +228,18 @@ Custom primitive integrations must initialize them with the same authenticated
|
|
|
228
228
|
scope, dispose them on identity changes, and wire their transport to an authorized
|
|
229
229
|
server. Standard browser integrations should use the facade instead. The Merry migration and production validation
|
|
230
230
|
are tracked in the unified message layer proposal.
|
|
231
|
+
|
|
232
|
+
When the browser reports offline, scheduled message restoration and reading
|
|
233
|
+
refreshes wait for the normal reconnect/wake path. Cached reads remain available;
|
|
234
|
+
unknown receipt counts stay unknown and an uncached reader detail page rejects
|
|
235
|
+
without attempting transport. A network failure arriving after the browser went
|
|
236
|
+
offline does not turn background synchronization into an operation error.
|
|
237
|
+
Authorization failures still revoke the corresponding cached data.
|
|
238
|
+
|
|
239
|
+
Reading entry points check the message scope even before `initialize()` finishes.
|
|
240
|
+
Concurrent observations share the permission lookup and batch their local writes.
|
|
241
|
+
Visibility/playback operations reject another known team's conversation; a
|
|
242
|
+
permission change in another team does not discard this team's observations.
|
|
243
|
+
Ordinary shutdown waits for accepted local reading writes, while access withdrawal
|
|
244
|
+
rejects late results. Retired asynchronous reading entries reject through promises;
|
|
245
|
+
refresh/subscription teardown remains safe.
|
|
@@ -63,6 +63,10 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
63
63
|
private closedSummary;
|
|
64
64
|
private connectionState;
|
|
65
65
|
private catchUp;
|
|
66
|
+
private readingAllowed;
|
|
67
|
+
private readingListeners;
|
|
68
|
+
private acceptedReading;
|
|
69
|
+
private readingAccess?;
|
|
66
70
|
private readingStarted;
|
|
67
71
|
private readingStarting?;
|
|
68
72
|
private withdrawn;
|
|
@@ -81,6 +85,10 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
81
85
|
importLegacyMessages(threadId: string, messages: T[]): Promise<void>;
|
|
82
86
|
getReadingSnapshot(): import("./readIndex.js").ReadIndexState | undefined;
|
|
83
87
|
subscribeReading(listener: () => void): () => void;
|
|
88
|
+
private setReadingAllowed;
|
|
89
|
+
private readingAuthority;
|
|
90
|
+
private authorizeReading;
|
|
91
|
+
private readingOperation;
|
|
84
92
|
refreshReading(): void;
|
|
85
93
|
synchronizeReading(cursor?: string, profileId?: string): Promise<import("./readIndex.js").ReadIndexState>;
|
|
86
94
|
/** Explicit text-history acknowledgement; unheard audio stays unread. */
|
|
@@ -31,6 +31,10 @@ export class ChatSession {
|
|
|
31
31
|
closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
|
|
32
32
|
connectionState = "closed";
|
|
33
33
|
catchUp;
|
|
34
|
+
readingAllowed = false;
|
|
35
|
+
readingListeners = new Set();
|
|
36
|
+
acceptedReading = new Set();
|
|
37
|
+
readingAccess;
|
|
34
38
|
readingStarted = false;
|
|
35
39
|
readingStarting;
|
|
36
40
|
withdrawn = false;
|
|
@@ -58,7 +62,7 @@ export class ChatSession {
|
|
|
58
62
|
return options.connection.onReach(event);
|
|
59
63
|
throw new Error("REACH_HANDLER_UNAVAILABLE");
|
|
60
64
|
} });
|
|
61
|
-
this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
|
|
65
|
+
this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
|
|
62
66
|
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
63
67
|
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess() });
|
|
64
68
|
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
@@ -74,7 +78,8 @@ export class ChatSession {
|
|
|
74
78
|
this.off.push(this.messages.subscribeAccessRevoked(() => { if (!this.withdrawn && !this.closed)
|
|
75
79
|
void this.revokeAccess().catch(error => options.onError(error)); }));
|
|
76
80
|
this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
|
|
77
|
-
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll())
|
|
81
|
+
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => { this.publishAll(); if (this.readingAllowed)
|
|
82
|
+
this.readingListeners.forEach(listener => listener()); }));
|
|
78
83
|
this.off.push(this.connection.watchMessages(message => {
|
|
79
84
|
try {
|
|
80
85
|
const decoded = options.decode(message);
|
|
@@ -97,7 +102,8 @@ export class ChatSession {
|
|
|
97
102
|
this.publishGlobal();
|
|
98
103
|
if (state === "connected") {
|
|
99
104
|
const since = Date.now();
|
|
100
|
-
this.catchUp.schedule("scope", () => this.
|
|
105
|
+
this.catchUp.schedule("scope", async () => { if (this.connectionState === "connected" && !this.closed && !this.withdrawn)
|
|
106
|
+
await this.messages.restore(since, options.activeThreadId); });
|
|
101
107
|
if (this.readingStarted)
|
|
102
108
|
this.reading.refresh();
|
|
103
109
|
this.wakeCounts();
|
|
@@ -132,6 +138,7 @@ export class ChatSession {
|
|
|
132
138
|
return;
|
|
133
139
|
this.assertOpen();
|
|
134
140
|
await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
|
|
141
|
+
this.setReadingAllowed(true);
|
|
135
142
|
await this.reading.start();
|
|
136
143
|
this.assertOpen();
|
|
137
144
|
this.readingStarted = true;
|
|
@@ -139,16 +146,60 @@ export class ChatSession {
|
|
|
139
146
|
}
|
|
140
147
|
/** One-time migration only: never establishes trusted history coverage. */
|
|
141
148
|
importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
|
|
142
|
-
getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
|
|
149
|
+
getReadingSnapshot() { return this.closed || this.withdrawn || !this.readingAllowed ? undefined : this.reading.getSnapshot(); }
|
|
143
150
|
subscribeReading(listener) { if (this.closed || this.withdrawn)
|
|
144
|
-
return () => { }; return this.
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
return () => { }; this.readingListeners.add(listener); return () => { this.readingListeners.delete(listener); }; }
|
|
152
|
+
setReadingAllowed(allowed) {
|
|
153
|
+
if (this.readingAllowed === allowed)
|
|
154
|
+
return;
|
|
155
|
+
const previous = this.getReadingSnapshot();
|
|
156
|
+
this.readingAllowed = allowed;
|
|
157
|
+
if (previous !== this.getReadingSnapshot())
|
|
158
|
+
this.readingListeners.forEach(listener => listener());
|
|
159
|
+
this.publishAll();
|
|
160
|
+
}
|
|
161
|
+
readingAuthority() {
|
|
162
|
+
return this.readingAccess ??= this.messages.scopeAccess().then(access => { if (!access.allowed)
|
|
163
|
+
this.setReadingAllowed(false); return access; }).finally(() => { this.readingAccess = undefined; });
|
|
164
|
+
}
|
|
165
|
+
async authorizeReading() {
|
|
166
|
+
const access = await this.readingAuthority();
|
|
167
|
+
if (this.withdrawn || !access.allowed)
|
|
168
|
+
throw new Error("CHAT_ACCESS_REVOKED");
|
|
169
|
+
await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
|
|
170
|
+
this.setReadingAllowed(true);
|
|
171
|
+
}
|
|
172
|
+
readingOperation(run, durable = true) {
|
|
173
|
+
if (this.closed || this.withdrawn)
|
|
174
|
+
return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
|
|
175
|
+
const task = (async () => {
|
|
176
|
+
await this.authorizeReading();
|
|
177
|
+
if (this.withdrawn)
|
|
178
|
+
throw new Error("CHAT_ACCESS_REVOKED");
|
|
179
|
+
if (!durable)
|
|
180
|
+
this.assertOpen();
|
|
181
|
+
const next = await run();
|
|
182
|
+
if (this.withdrawn || !(await this.readingAuthority()).allowed)
|
|
183
|
+
throw new Error("CHAT_ACCESS_REVOKED");
|
|
184
|
+
return next;
|
|
185
|
+
})();
|
|
186
|
+
if (durable) {
|
|
187
|
+
this.acceptedReading.add(task);
|
|
188
|
+
void task.finally(() => this.acceptedReading.delete(task)).catch(() => { });
|
|
189
|
+
}
|
|
190
|
+
return task;
|
|
191
|
+
}
|
|
192
|
+
refreshReading() {
|
|
193
|
+
if (this.closed || this.withdrawn)
|
|
194
|
+
return;
|
|
195
|
+
void this.authorizeReading().then(() => { if (!this.closed && !this.withdrawn)
|
|
196
|
+
this.reading.refresh(); }).catch(error => { if (!this.closed && !this.withdrawn)
|
|
197
|
+
this.options.onError(error); });
|
|
198
|
+
}
|
|
199
|
+
synchronizeReading(cursor, profileId) { return this.readingOperation(() => this.reading.synchronize(cursor, profileId), false); }
|
|
147
200
|
/** Explicit text-history acknowledgement; unheard audio stays unread. */
|
|
148
|
-
markRead(threadId, messages = []) { this.
|
|
149
|
-
|
|
150
|
-
updateReading(transform) { this.assertOpen(); return this.reading.update(transform).then(next => { if (this.withdrawn)
|
|
151
|
-
throw new Error("CHAT_ACCESS_REVOKED"); return next; }); }
|
|
201
|
+
markRead(threadId, messages = []) { return this.readingOperation(() => this.reading.markRead(threadId, messages)); }
|
|
202
|
+
updateReading(transform) { return this.readingOperation(() => this.reading.update(transform)); }
|
|
152
203
|
assertOpen() { if (this.withdrawn)
|
|
153
204
|
throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
|
|
154
205
|
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
@@ -277,7 +328,7 @@ export class ChatSession {
|
|
|
277
328
|
}
|
|
278
329
|
if (!threadId)
|
|
279
330
|
throw new Error("INVALID_READING_THREAD");
|
|
280
|
-
return (event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids))
|
|
331
|
+
return this.readingOperation(() => event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids));
|
|
281
332
|
}
|
|
282
333
|
/** Bridge transports can delegate social events without maintaining clients. */
|
|
283
334
|
async handleReach(event) {
|
|
@@ -297,7 +348,7 @@ export class ChatSession {
|
|
|
297
348
|
if (event.payload.profileId !== this.options.messages.scope.profileId)
|
|
298
349
|
throw new Error("Reading reach identity mismatch");
|
|
299
350
|
await this.initialize();
|
|
300
|
-
await this.
|
|
351
|
+
await this.synchronizeReading(event.payload.syncCursor, event.payload.profileId);
|
|
301
352
|
this.assertOpen();
|
|
302
353
|
}
|
|
303
354
|
return "handled";
|
|
@@ -378,7 +429,7 @@ export class ChatSession {
|
|
|
378
429
|
let snapshot = this.snapshots.get(threadId);
|
|
379
430
|
if (!snapshot) {
|
|
380
431
|
const messages = this.messages.getSnapshot(threadId);
|
|
381
|
-
const state = this.
|
|
432
|
+
const state = this.getReadingSnapshot();
|
|
382
433
|
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), readCounts: this.countSnapshots.get(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
|
|
383
434
|
this.snapshots.set(threadId, snapshot);
|
|
384
435
|
}
|
|
@@ -425,7 +476,7 @@ export class ChatSession {
|
|
|
425
476
|
}
|
|
426
477
|
buildGlobal() {
|
|
427
478
|
const ids = new Set(this.snapshots.keys());
|
|
428
|
-
for (const thread of this.
|
|
479
|
+
for (const thread of this.getReadingSnapshot()?.threads ?? [])
|
|
429
480
|
if (thread.teamId === this.options.messages.scope.teamId)
|
|
430
481
|
ids.add(thread.conversationId ?? thread.id);
|
|
431
482
|
for (const outgoing of this.sender.snapshot())
|
|
@@ -434,7 +485,7 @@ export class ChatSession {
|
|
|
434
485
|
const snapshot = this.getSnapshot(threadId);
|
|
435
486
|
return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
|
|
436
487
|
});
|
|
437
|
-
const state = this.
|
|
488
|
+
const state = this.getReadingSnapshot();
|
|
438
489
|
const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
|
|
439
490
|
return { threads, unreadMessageCount: known ? threads.reduce((sum, t) => sum + (t.unread?.unreadMessageCount ?? 0), 0) : undefined, badgeCount: known ? threads.reduce((sum, t) => sum + (t.unread?.badgeCount ?? 0), 0) : undefined, connection: this.connectionState };
|
|
440
491
|
}
|
|
@@ -482,6 +533,7 @@ export class ChatSession {
|
|
|
482
533
|
this.globalSnapshot = undefined;
|
|
483
534
|
this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
|
|
484
535
|
this.globalListeners.forEach(listener => listener());
|
|
536
|
+
this.readingListeners.forEach(listener => listener());
|
|
485
537
|
if (!this.options.sharedConnection)
|
|
486
538
|
this.connection.close();
|
|
487
539
|
return this.revoking = Promise.resolve().then(async () => {
|
|
@@ -528,8 +580,10 @@ export class ChatSession {
|
|
|
528
580
|
if (!this.options.sharedConnection)
|
|
529
581
|
this.connection.close();
|
|
530
582
|
const senderClosing = this.sender.close();
|
|
583
|
+
const readingBarrier = Promise.allSettled([...this.acceptedReading]);
|
|
584
|
+
const readingClosing = readingBarrier.then(() => this.reading.close());
|
|
531
585
|
try {
|
|
532
|
-
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing,
|
|
586
|
+
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, readingBarrier]))]);
|
|
533
587
|
const failed = results.find((result) => result.status === "rejected");
|
|
534
588
|
if (failed)
|
|
535
589
|
throw failed.reason;
|
|
@@ -537,6 +591,7 @@ export class ChatSession {
|
|
|
537
591
|
finally {
|
|
538
592
|
this.listeners.clear();
|
|
539
593
|
this.globalListeners.clear();
|
|
594
|
+
this.readingListeners.clear();
|
|
540
595
|
this.snapshots.clear();
|
|
541
596
|
this.countSnapshots.clear();
|
|
542
597
|
this.historyEdges.clear();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Browser connectivity is advisory: only suppress network errors while the
|
|
2
|
+
* browser explicitly reports offline. Permission and data errors stay visible. */
|
|
3
|
+
export declare function isOffline(): boolean;
|
|
4
|
+
export declare function isOfflineNetworkFailure(error: unknown): boolean;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Browser connectivity is advisory: only suppress network errors while the
|
|
2
|
+
* browser explicitly reports offline. Permission and data errors stay visible. */
|
|
3
|
+
export function isOffline() {
|
|
4
|
+
return typeof navigator !== "undefined" && navigator.onLine === false;
|
|
5
|
+
}
|
|
6
|
+
export function isOfflineNetworkFailure(error) {
|
|
7
|
+
return isOffline() && (error instanceof TypeError || error?.name === "AbortError");
|
|
8
|
+
}
|
|
@@ -293,6 +293,7 @@ export class DurableChatSender {
|
|
|
293
293
|
this.offConnection();
|
|
294
294
|
this.listeners.clear();
|
|
295
295
|
this.channel?.close();
|
|
296
|
+
this.channel = undefined;
|
|
296
297
|
try {
|
|
297
298
|
await this.ready;
|
|
298
299
|
await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
1
2
|
import { WindowedTasks } from "./coalescer.js";
|
|
2
3
|
import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
|
|
3
4
|
/** Local-first message facade; hosts never select a cache or network source. */
|
|
@@ -48,7 +49,8 @@ export class MessageClient {
|
|
|
48
49
|
this.invalidate(thread);
|
|
49
50
|
else
|
|
50
51
|
options.onError?.(error, thread); });
|
|
51
|
-
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) =>
|
|
52
|
+
this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => { if (!isOfflineNetworkFailure(error))
|
|
53
|
+
options.onError?.(error, thread); });
|
|
52
54
|
if (typeof BroadcastChannel !== "undefined") {
|
|
53
55
|
this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
|
|
54
56
|
this.channel.onmessage = event => {
|
|
@@ -109,15 +111,17 @@ export class MessageClient {
|
|
|
109
111
|
subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
|
|
110
112
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
111
113
|
async restore(since, threadId) {
|
|
114
|
+
if (this.closed || this.closing || this.scopeDenied || isOffline())
|
|
115
|
+
return;
|
|
112
116
|
const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
|
|
113
117
|
await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
114
|
-
while (queue.length && !this.closed && !this.closing) {
|
|
118
|
+
while (queue.length && !this.closed && !this.closing && !this.scopeDenied && !isOffline()) {
|
|
115
119
|
const window = queue.shift();
|
|
116
120
|
try {
|
|
117
121
|
await this.synchronize(window.thread);
|
|
118
122
|
}
|
|
119
123
|
catch (error) {
|
|
120
|
-
if (!this.closed && !this.closing)
|
|
124
|
+
if (!this.closed && !this.closing && !isOfflineNetworkFailure(error))
|
|
121
125
|
this.options.onError?.(error, window.thread);
|
|
122
126
|
}
|
|
123
127
|
}
|
|
@@ -198,8 +202,11 @@ export class MessageClient {
|
|
|
198
202
|
}
|
|
199
203
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
200
204
|
invalidate(thread) {
|
|
201
|
-
if (!this.closed && !this.closing && !this.revoked.has(thread))
|
|
202
|
-
this.syncTasks.schedule(thread, () =>
|
|
205
|
+
if (!this.closed && !this.closing && !this.scopeDenied && !isOffline() && !this.revoked.has(thread))
|
|
206
|
+
this.syncTasks.schedule(thread, async () => {
|
|
207
|
+
if (!isOffline())
|
|
208
|
+
await this.synchronize(thread);
|
|
209
|
+
});
|
|
203
210
|
}
|
|
204
211
|
async fallback(thread, limit, before) {
|
|
205
212
|
await this.ensureScope();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline } from "./connectivity.js";
|
|
1
2
|
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
2
3
|
import { messageScopeKey } from "./messageStore.js";
|
|
3
4
|
export class MessageReadersError extends Error {
|
|
@@ -95,6 +96,8 @@ export class MessageReadersClient {
|
|
|
95
96
|
const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
|
|
96
97
|
if (this.lifetime.signal.aborted)
|
|
97
98
|
throw new Error("MESSAGE_READERS_CLOSED");
|
|
99
|
+
if (isOffline())
|
|
100
|
+
throw new MessageReadersError(0, "MESSAGE_READERS_OFFLINE_UNAVAILABLE");
|
|
98
101
|
const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
|
|
99
102
|
const controller = new AbortController();
|
|
100
103
|
const cancel = () => controller.abort(this.lifetime.signal.reason);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
1
2
|
import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
|
|
2
3
|
import { messageScopeKey } from "./messageStore.js";
|
|
3
4
|
let subscriptionSequence = 0;
|
|
@@ -59,6 +60,8 @@ export class ReadCountsClient {
|
|
|
59
60
|
return envelope.data;
|
|
60
61
|
}
|
|
61
62
|
subscription(threadId, release) {
|
|
63
|
+
if (release && isOffline())
|
|
64
|
+
return Promise.resolve();
|
|
62
65
|
return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
|
|
63
66
|
}
|
|
64
67
|
renew() {
|
|
@@ -98,7 +101,8 @@ export class ReadCountsClient {
|
|
|
98
101
|
clearInterval(this.heartbeat);
|
|
99
102
|
this.heartbeat = undefined;
|
|
100
103
|
}
|
|
101
|
-
|
|
104
|
+
if (!isOfflineNetworkFailure(error))
|
|
105
|
+
this.options.onError?.(error);
|
|
102
106
|
}).finally(() => { if (this.subscriptionPending === pending)
|
|
103
107
|
this.subscriptionPending = undefined; });
|
|
104
108
|
this.subscriptionPending = pending;
|
|
@@ -197,6 +201,8 @@ export class ReadCountsClient {
|
|
|
197
201
|
if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
|
|
198
202
|
return;
|
|
199
203
|
}
|
|
204
|
+
if (isOffline())
|
|
205
|
+
return;
|
|
200
206
|
const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
|
|
201
207
|
if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
|
|
202
208
|
return;
|
|
@@ -217,6 +223,10 @@ export class ReadCountsClient {
|
|
|
217
223
|
this.renew();
|
|
218
224
|
}
|
|
219
225
|
catch (error) {
|
|
226
|
+
if (isOfflineNetworkFailure(error)) {
|
|
227
|
+
this.dirty = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
220
230
|
if (!controller.signal.aborted && generation === this.generation && !this.closed) {
|
|
221
231
|
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
|
|
222
232
|
this.denyCache();
|
|
@@ -79,7 +79,7 @@ export declare function unreadInThread(state: ReadIndexState, threadId: string):
|
|
|
79
79
|
};
|
|
80
80
|
export type ReadIndexVersions = Readonly<Record<string, number>>;
|
|
81
81
|
export type ReadIndexTransform = (current: ReadIndexState, versions?: ReadIndexVersions) => ReadIndexState;
|
|
82
|
-
export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions): void;
|
|
82
|
+
export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions, teamId?: string): void;
|
|
83
83
|
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
84
84
|
* queued observations. Other teams' pending operations must survive. */
|
|
85
85
|
export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -165,8 +165,8 @@ export function unreadInThread(state, threadId) {
|
|
|
165
165
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
166
166
|
}
|
|
167
167
|
function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
|
|
168
|
-
export function assertReadIndexVersions(expected, current = {}) {
|
|
169
|
-
const teams = new Set([...Object.keys(expected), ...Object.keys(current)]);
|
|
168
|
+
export function assertReadIndexVersions(expected, current = {}, teamId) {
|
|
169
|
+
const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
|
|
170
170
|
if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
|
|
171
171
|
throw new Error("READ_SCOPE_CHANGED");
|
|
172
172
|
}
|
|
@@ -5,6 +5,7 @@ export type ReadingClientOptions = {
|
|
|
5
5
|
profileId: string;
|
|
6
6
|
sessionToken: string;
|
|
7
7
|
hubHttpUrl: string;
|
|
8
|
+
teamId?: string;
|
|
8
9
|
persistence?: ReadIndexPersistence;
|
|
9
10
|
storageScope?: string;
|
|
10
11
|
migrateLegacy?: boolean;
|
|
@@ -21,6 +22,7 @@ export declare class ReadingClient {
|
|
|
21
22
|
private closing?;
|
|
22
23
|
private revoking?;
|
|
23
24
|
private versions?;
|
|
25
|
+
private prepared;
|
|
24
26
|
private accepted;
|
|
25
27
|
private persistence;
|
|
26
28
|
private sync;
|
|
@@ -41,7 +43,9 @@ export declare class ReadingClient {
|
|
|
41
43
|
refresh: () => void;
|
|
42
44
|
synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
|
|
43
45
|
update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
46
|
+
private updateScoped;
|
|
44
47
|
prepareTeam(teamId: string, version: number): Promise<void>;
|
|
48
|
+
private assertThreadTeam;
|
|
45
49
|
markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
|
|
46
50
|
recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
47
51
|
recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isValidChatTeamId } from "./thread.js";
|
|
2
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
1
3
|
import { recordConversationRead } from "./conversationRead.js";
|
|
2
4
|
import { BatchedReadIndex } from "./batchedReadIndex.js";
|
|
3
5
|
import { WindowedTasks } from "./coalescer.js";
|
|
@@ -12,6 +14,7 @@ export class ReadingClient {
|
|
|
12
14
|
closing;
|
|
13
15
|
revoking;
|
|
14
16
|
versions;
|
|
17
|
+
prepared = new Map();
|
|
15
18
|
accepted = new Set();
|
|
16
19
|
persistence;
|
|
17
20
|
sync;
|
|
@@ -25,6 +28,8 @@ export class ReadingClient {
|
|
|
25
28
|
generation = 0;
|
|
26
29
|
constructor(options) {
|
|
27
30
|
this.options = options;
|
|
31
|
+
if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
|
|
32
|
+
throw new Error("INVALID_READ_TEAM");
|
|
28
33
|
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
|
|
29
34
|
this.backing = storage;
|
|
30
35
|
this.persistence = new BatchedReadIndex(storage);
|
|
@@ -40,7 +45,7 @@ export class ReadingClient {
|
|
|
40
45
|
this.listeners.forEach(listener => listener());
|
|
41
46
|
}
|
|
42
47
|
failed(error) {
|
|
43
|
-
if (this.closed)
|
|
48
|
+
if (this.closed || isOfflineNetworkFailure(error))
|
|
44
49
|
return;
|
|
45
50
|
this.options.onError?.(error);
|
|
46
51
|
if (/HTTP_(400|401|403)/.test(String(error)) || (typeof navigator !== "undefined" && !navigator.onLine) || this.retry)
|
|
@@ -63,8 +68,9 @@ export class ReadingClient {
|
|
|
63
68
|
this.refresh();
|
|
64
69
|
}
|
|
65
70
|
refresh = () => {
|
|
66
|
-
if (!this.closed)
|
|
67
|
-
this.tasks.schedule("reading", async () => {
|
|
71
|
+
if (!this.closed && !isOffline())
|
|
72
|
+
this.tasks.schedule("reading", async () => { if (!isOffline())
|
|
73
|
+
await this.synchronize(); });
|
|
68
74
|
};
|
|
69
75
|
async synchronize(targetCursor, profileId) {
|
|
70
76
|
if (this.closed)
|
|
@@ -92,6 +98,11 @@ export class ReadingClient {
|
|
|
92
98
|
throw new Error("Reading projection has not reached the announced version");
|
|
93
99
|
}
|
|
94
100
|
update(transform) {
|
|
101
|
+
// Advanced transforms may replace profile-wide state. Typed observation
|
|
102
|
+
// methods below only derive changes for their configured team.
|
|
103
|
+
return this.updateScoped(transform);
|
|
104
|
+
}
|
|
105
|
+
updateScoped(transform, teamId) {
|
|
95
106
|
if (this.closed)
|
|
96
107
|
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
97
108
|
// One shared metadata read per local authority generation, rather than one
|
|
@@ -100,7 +111,7 @@ export class ReadingClient {
|
|
|
100
111
|
const task = (async () => {
|
|
101
112
|
const expected = await versions;
|
|
102
113
|
const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
|
|
103
|
-
assertReadIndexVersions(expected, currentVersions);
|
|
114
|
+
assertReadIndexVersions(expected, currentVersions, teamId);
|
|
104
115
|
return transform(state);
|
|
105
116
|
});
|
|
106
117
|
if (!this.closed) {
|
|
@@ -109,28 +120,48 @@ export class ReadingClient {
|
|
|
109
120
|
this.refresh();
|
|
110
121
|
}
|
|
111
122
|
return next;
|
|
112
|
-
})().catch(error => {
|
|
113
|
-
|
|
123
|
+
})().catch(error => {
|
|
124
|
+
if (String(error).includes("READ_SCOPE_CHANGED")) {
|
|
125
|
+
if (teamId !== undefined)
|
|
126
|
+
this.withdraw();
|
|
127
|
+
else
|
|
128
|
+
this.versions = undefined;
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
});
|
|
114
132
|
this.accepted.add(task);
|
|
115
133
|
void task.finally(() => this.accepted.delete(task)).catch(() => { });
|
|
116
134
|
return task;
|
|
117
135
|
}
|
|
118
|
-
|
|
136
|
+
prepareTeam(teamId, version) {
|
|
119
137
|
if (this.closed)
|
|
120
|
-
|
|
121
|
-
if (version
|
|
122
|
-
|
|
138
|
+
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
139
|
+
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 0)
|
|
140
|
+
return Promise.reject(new Error("INVALID_READ_SCOPE_VERSION"));
|
|
141
|
+
const previous = this.prepared.get(teamId);
|
|
142
|
+
if (previous && previous.version >= version)
|
|
143
|
+
return previous.task;
|
|
144
|
+
const task = (async () => {
|
|
145
|
+
if (version > 0)
|
|
146
|
+
await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
|
|
123
147
|
this.versions = undefined;
|
|
124
|
-
}
|
|
148
|
+
})().catch(error => { if (this.prepared.get(teamId)?.task === task)
|
|
149
|
+
this.prepared.delete(teamId); throw error; });
|
|
150
|
+
this.prepared.set(teamId, { version, task });
|
|
151
|
+
return task;
|
|
152
|
+
}
|
|
153
|
+
assertThreadTeam(state, threadId) {
|
|
154
|
+
if (this.options.teamId !== undefined && state.threads.some(thread => (thread.id === threadId || thread.conversationId === threadId) && thread.teamId !== undefined && thread.teamId !== this.options.teamId))
|
|
155
|
+
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
125
156
|
}
|
|
126
157
|
markRead(threadId, messages = []) {
|
|
127
|
-
return this.
|
|
158
|
+
return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages); }, this.options.teamId);
|
|
128
159
|
}
|
|
129
160
|
recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
|
|
130
161
|
recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
|
|
131
162
|
record(threadId, ids, readPolicy) {
|
|
132
163
|
const messageIds = [...new Set(ids)];
|
|
133
|
-
return this.
|
|
164
|
+
return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state); }, this.options.teamId);
|
|
134
165
|
}
|
|
135
166
|
withdraw() { this.stop(); }
|
|
136
167
|
stop() {
|
package/package.json
CHANGED