@pmate/chat-sdk 0.12.0-next.24 → 0.12.0-next.26
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 +8 -0
- package/lib/runtime/chatSession.d.ts +8 -0
- package/lib/runtime/chatSession.js +70 -16
- package/lib/runtime/durableSender.js +1 -0
- package/lib/runtime/readIndex.d.ts +9 -1
- package/lib/runtime/readIndex.js +128 -10
- package/lib/runtime/readingClient.d.ts +4 -0
- package/lib/runtime/readingClient.js +39 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -235,3 +235,11 @@ unknown receipt counts stay unknown and an uncached reader detail page rejects
|
|
|
235
235
|
without attempting transport. A network failure arriving after the browser went
|
|
236
236
|
offline does not turn background synchronization into an operation error.
|
|
237
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);
|
|
@@ -133,6 +138,7 @@ export class ChatSession {
|
|
|
133
138
|
return;
|
|
134
139
|
this.assertOpen();
|
|
135
140
|
await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
|
|
141
|
+
this.setReadingAllowed(true);
|
|
136
142
|
await this.reading.start();
|
|
137
143
|
this.assertOpen();
|
|
138
144
|
this.readingStarted = true;
|
|
@@ -140,16 +146,60 @@ export class ChatSession {
|
|
|
140
146
|
}
|
|
141
147
|
/** One-time migration only: never establishes trusted history coverage. */
|
|
142
148
|
importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
|
|
143
|
-
getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
|
|
149
|
+
getReadingSnapshot() { return this.closed || this.withdrawn || !this.readingAllowed ? undefined : this.reading.getSnapshot(); }
|
|
144
150
|
subscribeReading(listener) { if (this.closed || this.withdrawn)
|
|
145
|
-
return () => { }; return this.
|
|
146
|
-
|
|
147
|
-
|
|
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); }
|
|
148
200
|
/** Explicit text-history acknowledgement; unheard audio stays unread. */
|
|
149
|
-
markRead(threadId, messages = []) { this.
|
|
150
|
-
|
|
151
|
-
updateReading(transform) { this.assertOpen(); return this.reading.update(transform).then(next => { if (this.withdrawn)
|
|
152
|
-
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)); }
|
|
153
203
|
assertOpen() { if (this.withdrawn)
|
|
154
204
|
throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
|
|
155
205
|
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
@@ -278,7 +328,7 @@ export class ChatSession {
|
|
|
278
328
|
}
|
|
279
329
|
if (!threadId)
|
|
280
330
|
throw new Error("INVALID_READING_THREAD");
|
|
281
|
-
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));
|
|
282
332
|
}
|
|
283
333
|
/** Bridge transports can delegate social events without maintaining clients. */
|
|
284
334
|
async handleReach(event) {
|
|
@@ -298,7 +348,7 @@ export class ChatSession {
|
|
|
298
348
|
if (event.payload.profileId !== this.options.messages.scope.profileId)
|
|
299
349
|
throw new Error("Reading reach identity mismatch");
|
|
300
350
|
await this.initialize();
|
|
301
|
-
await this.
|
|
351
|
+
await this.synchronizeReading(event.payload.syncCursor, event.payload.profileId);
|
|
302
352
|
this.assertOpen();
|
|
303
353
|
}
|
|
304
354
|
return "handled";
|
|
@@ -379,7 +429,7 @@ export class ChatSession {
|
|
|
379
429
|
let snapshot = this.snapshots.get(threadId);
|
|
380
430
|
if (!snapshot) {
|
|
381
431
|
const messages = this.messages.getSnapshot(threadId);
|
|
382
|
-
const state = this.
|
|
432
|
+
const state = this.getReadingSnapshot();
|
|
383
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 };
|
|
384
434
|
this.snapshots.set(threadId, snapshot);
|
|
385
435
|
}
|
|
@@ -426,7 +476,7 @@ export class ChatSession {
|
|
|
426
476
|
}
|
|
427
477
|
buildGlobal() {
|
|
428
478
|
const ids = new Set(this.snapshots.keys());
|
|
429
|
-
for (const thread of this.
|
|
479
|
+
for (const thread of this.getReadingSnapshot()?.threads ?? [])
|
|
430
480
|
if (thread.teamId === this.options.messages.scope.teamId)
|
|
431
481
|
ids.add(thread.conversationId ?? thread.id);
|
|
432
482
|
for (const outgoing of this.sender.snapshot())
|
|
@@ -435,7 +485,7 @@ export class ChatSession {
|
|
|
435
485
|
const snapshot = this.getSnapshot(threadId);
|
|
436
486
|
return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
|
|
437
487
|
});
|
|
438
|
-
const state = this.
|
|
488
|
+
const state = this.getReadingSnapshot();
|
|
439
489
|
const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
|
|
440
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 };
|
|
441
491
|
}
|
|
@@ -483,6 +533,7 @@ export class ChatSession {
|
|
|
483
533
|
this.globalSnapshot = undefined;
|
|
484
534
|
this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
|
|
485
535
|
this.globalListeners.forEach(listener => listener());
|
|
536
|
+
this.readingListeners.forEach(listener => listener());
|
|
486
537
|
if (!this.options.sharedConnection)
|
|
487
538
|
this.connection.close();
|
|
488
539
|
return this.revoking = Promise.resolve().then(async () => {
|
|
@@ -529,8 +580,10 @@ export class ChatSession {
|
|
|
529
580
|
if (!this.options.sharedConnection)
|
|
530
581
|
this.connection.close();
|
|
531
582
|
const senderClosing = this.sender.close();
|
|
583
|
+
const readingBarrier = Promise.allSettled([...this.acceptedReading]);
|
|
584
|
+
const readingClosing = readingBarrier.then(() => this.reading.close());
|
|
532
585
|
try {
|
|
533
|
-
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]))]);
|
|
534
587
|
const failed = results.find((result) => result.status === "rejected");
|
|
535
588
|
if (failed)
|
|
536
589
|
throw failed.reason;
|
|
@@ -538,6 +591,7 @@ export class ChatSession {
|
|
|
538
591
|
finally {
|
|
539
592
|
this.listeners.clear();
|
|
540
593
|
this.globalListeners.clear();
|
|
594
|
+
this.readingListeners.clear();
|
|
541
595
|
this.snapshots.clear();
|
|
542
596
|
this.countSnapshots.clear();
|
|
543
597
|
this.historyEdges.clear();
|
|
@@ -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] : [])]);
|
|
@@ -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;
|
|
@@ -100,12 +100,20 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
100
100
|
private migrateLegacy;
|
|
101
101
|
private database;
|
|
102
102
|
private profiles;
|
|
103
|
+
private threadRows;
|
|
104
|
+
private messageRows;
|
|
105
|
+
private positionRows;
|
|
106
|
+
private normalizedStates;
|
|
103
107
|
private versions;
|
|
104
108
|
private closed;
|
|
105
109
|
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
|
|
106
110
|
private transaction;
|
|
107
111
|
private key;
|
|
112
|
+
private hydrate;
|
|
108
113
|
private loadCurrent;
|
|
114
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
115
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
116
|
+
private persist;
|
|
109
117
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
110
118
|
loadVersioned(appId: string, profileId: string): Promise<{
|
|
111
119
|
state: ReadIndexState | undefined;
|
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
|
}
|
|
@@ -190,6 +190,10 @@ export class IndexedDbReadIndex {
|
|
|
190
190
|
migrateLegacy;
|
|
191
191
|
database;
|
|
192
192
|
profiles;
|
|
193
|
+
threadRows;
|
|
194
|
+
messageRows;
|
|
195
|
+
positionRows;
|
|
196
|
+
normalizedStates = new WeakSet();
|
|
193
197
|
versions;
|
|
194
198
|
closed = false;
|
|
195
199
|
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
|
|
@@ -201,6 +205,10 @@ export class IndexedDbReadIndex {
|
|
|
201
205
|
// The empty schema retains the legacy out-of-line primary key and records.
|
|
202
206
|
this.database.version(1).stores({ profiles: "" });
|
|
203
207
|
this.database.version(2).stores({ profiles: "", versions: "" });
|
|
208
|
+
this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
|
|
209
|
+
this.positionRows = this.database.table("readPositions");
|
|
210
|
+
this.threadRows = this.database.table("readThreads");
|
|
211
|
+
this.messageRows = this.database.table("readMessages");
|
|
204
212
|
this.profiles = this.database.table("profiles");
|
|
205
213
|
this.versions = this.database.table("versions");
|
|
206
214
|
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
@@ -211,7 +219,7 @@ export class IndexedDbReadIndex {
|
|
|
211
219
|
throw new Error("READ_STORAGE_CLOSED");
|
|
212
220
|
let started = false;
|
|
213
221
|
try {
|
|
214
|
-
return await this.database.transaction(mode, this.profiles, this.versions, async () => {
|
|
222
|
+
return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
|
|
215
223
|
if (this.closed)
|
|
216
224
|
throw new Error("READ_STORAGE_CLOSED");
|
|
217
225
|
started = true;
|
|
@@ -230,25 +238,136 @@ export class IndexedDbReadIndex {
|
|
|
230
238
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
231
239
|
}
|
|
232
240
|
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
241
|
+
async hydrate(current, key) {
|
|
242
|
+
if (current.normalized) {
|
|
243
|
+
const owner = JSON.stringify(key);
|
|
244
|
+
const [threads, messages, positions] = await Promise.all([
|
|
245
|
+
this.threadRows.where("owner").equals(owner).toArray(),
|
|
246
|
+
this.messageRows.where("owner").equals(owner).toArray(),
|
|
247
|
+
this.positionRows.where("owner").equals(owner).toArray(),
|
|
248
|
+
]);
|
|
249
|
+
const byThread = new Map();
|
|
250
|
+
for (const row of messages) {
|
|
251
|
+
if (!byThread.has(row.thread))
|
|
252
|
+
byThread.set(row.thread, new Map());
|
|
253
|
+
byThread.get(row.thread).set(row.id, row.value);
|
|
254
|
+
}
|
|
255
|
+
const byPosition = new Map(positions.map(row => [row.id, row]));
|
|
256
|
+
const byId = new Map(threads.map(row => [row.id, row]));
|
|
257
|
+
const { normalized: _normalized, threadOrder, ...state } = current;
|
|
258
|
+
const result = { ...state, threads: (threadOrder ?? []).map(id => {
|
|
259
|
+
const row = byId.get(id);
|
|
260
|
+
const position = byPosition.get(id);
|
|
261
|
+
if (!row || !position)
|
|
262
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
263
|
+
return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
|
|
264
|
+
const message = byThread.get(id)?.get(messageId);
|
|
265
|
+
if (!message)
|
|
266
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
267
|
+
return message;
|
|
268
|
+
}) };
|
|
269
|
+
}) };
|
|
270
|
+
this.normalizedStates.add(result);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
return current;
|
|
274
|
+
}
|
|
233
275
|
async loadCurrent(appId, profileId) {
|
|
234
276
|
const current = await this.profiles.get(this.key(appId, profileId));
|
|
235
|
-
if (current
|
|
277
|
+
if (current?.normalized)
|
|
278
|
+
return this.hydrate(current, this.key(appId, profileId));
|
|
279
|
+
if (current) {
|
|
280
|
+
if (this.migrateLegacy)
|
|
281
|
+
await this.persist(appId, profileId, undefined, current);
|
|
236
282
|
return current;
|
|
283
|
+
}
|
|
284
|
+
if (!this.migrateLegacy || this.storageScope === undefined)
|
|
285
|
+
return undefined;
|
|
237
286
|
const markerKey = [appId, profileId, "legacy-owner-v1"];
|
|
238
287
|
const marker = await this.profiles.get(markerKey);
|
|
239
288
|
if (marker && marker.legacyOwner !== this.storageScope)
|
|
240
289
|
return undefined;
|
|
241
|
-
const
|
|
242
|
-
if (!
|
|
290
|
+
const legacyRow = await this.profiles.get([appId, profileId]);
|
|
291
|
+
if (!legacyRow)
|
|
243
292
|
return undefined;
|
|
293
|
+
const legacy = await this.hydrate(legacyRow, [appId, profileId]);
|
|
244
294
|
if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
|
|
245
295
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
246
296
|
const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
|
|
247
297
|
await this.profiles.put(ownership, markerKey);
|
|
248
|
-
await this.
|
|
298
|
+
await this.persist(appId, profileId, undefined, legacy);
|
|
249
299
|
await this.profiles.delete([appId, profileId]);
|
|
300
|
+
const oldOwner = JSON.stringify([appId, profileId]);
|
|
301
|
+
await this.threadRows.where("owner").equals(oldOwner).delete();
|
|
302
|
+
await this.messageRows.where("owner").equals(oldOwner).delete();
|
|
303
|
+
await this.positionRows.where("owner").equals(oldOwner).delete();
|
|
250
304
|
return legacy;
|
|
251
305
|
}
|
|
306
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
307
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
308
|
+
async persist(appId, profileId, previous, next) {
|
|
309
|
+
if (previous && !this.normalizedStates.has(previous))
|
|
310
|
+
previous = undefined;
|
|
311
|
+
const owner = JSON.stringify(this.key(appId, profileId));
|
|
312
|
+
const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
|
|
313
|
+
const nextIds = new Set(next.threads.map(thread => thread.id));
|
|
314
|
+
if (nextIds.size !== next.threads.length)
|
|
315
|
+
throw new Error("DUPLICATE_READ_THREAD");
|
|
316
|
+
const threadWrites = [], messageWrites = [], positionWrites = [];
|
|
317
|
+
const messageDeletes = [];
|
|
318
|
+
for (const thread of next.threads) {
|
|
319
|
+
const old = oldThreads.get(thread.id);
|
|
320
|
+
if (old === thread)
|
|
321
|
+
continue;
|
|
322
|
+
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
323
|
+
const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
|
|
324
|
+
if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
325
|
+
positionWrites.push(position);
|
|
326
|
+
const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
|
|
327
|
+
if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
|
|
328
|
+
const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
|
|
329
|
+
const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
|
|
330
|
+
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
331
|
+
threadWrites.push(metadata);
|
|
332
|
+
}
|
|
333
|
+
if (old?.messages === messages)
|
|
334
|
+
continue;
|
|
335
|
+
const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
|
|
336
|
+
const ids = new Set();
|
|
337
|
+
for (const message of messages) {
|
|
338
|
+
if (ids.has(message.id))
|
|
339
|
+
throw new Error("INVALID_READ_MESSAGE");
|
|
340
|
+
ids.add(message.id);
|
|
341
|
+
const prior = byId.get(message.id);
|
|
342
|
+
if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
|
|
343
|
+
messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
|
|
344
|
+
}
|
|
345
|
+
for (const id of byId.keys())
|
|
346
|
+
if (!ids.has(id))
|
|
347
|
+
messageDeletes.push([owner, thread.id, id]);
|
|
348
|
+
}
|
|
349
|
+
for (const id of oldThreads.keys())
|
|
350
|
+
if (!nextIds.has(id)) {
|
|
351
|
+
await this.threadRows.delete([owner, id]);
|
|
352
|
+
await this.positionRows.delete([owner, id]);
|
|
353
|
+
await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
|
|
354
|
+
}
|
|
355
|
+
// Bound each IndexedDB bulk request. A snapshot and its cursor still commit
|
|
356
|
+
// atomically; bounding total snapshot transaction bytes is separate work.
|
|
357
|
+
for (let offset = 0; offset < positionWrites.length; offset += 128)
|
|
358
|
+
await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
|
|
359
|
+
for (let offset = 0; offset < threadWrites.length; offset += 128)
|
|
360
|
+
await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
|
|
361
|
+
for (let offset = 0; offset < messageWrites.length; offset += 128)
|
|
362
|
+
await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
|
|
363
|
+
for (let offset = 0; offset < messageDeletes.length; offset += 128)
|
|
364
|
+
await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
|
|
365
|
+
const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
|
|
366
|
+
const nextHeader = header(next);
|
|
367
|
+
if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
|
|
368
|
+
await this.profiles.put(nextHeader, this.key(appId, profileId));
|
|
369
|
+
this.normalizedStates.add(next);
|
|
370
|
+
}
|
|
252
371
|
async load(appId, profileId) {
|
|
253
372
|
return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
|
|
254
373
|
}
|
|
@@ -270,7 +389,7 @@ export class IndexedDbReadIndex {
|
|
|
270
389
|
return state;
|
|
271
390
|
const next = removeReadTeam(state, teamId, knownThreadIds);
|
|
272
391
|
await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
|
|
273
|
-
await this.
|
|
392
|
+
await this.persist(appId, profileId, state, next);
|
|
274
393
|
return next;
|
|
275
394
|
});
|
|
276
395
|
}
|
|
@@ -280,8 +399,7 @@ export class IndexedDbReadIndex {
|
|
|
280
399
|
const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
281
400
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
282
401
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
283
|
-
|
|
284
|
-
await this.profiles.put(next, this.key(appId, profileId));
|
|
402
|
+
await this.persist(appId, profileId, previous, next);
|
|
285
403
|
return next;
|
|
286
404
|
});
|
|
287
405
|
}
|
|
@@ -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,4 @@
|
|
|
1
|
+
import { isValidChatTeamId } from "./thread.js";
|
|
1
2
|
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
2
3
|
import { recordConversationRead } from "./conversationRead.js";
|
|
3
4
|
import { BatchedReadIndex } from "./batchedReadIndex.js";
|
|
@@ -13,6 +14,7 @@ export class ReadingClient {
|
|
|
13
14
|
closing;
|
|
14
15
|
revoking;
|
|
15
16
|
versions;
|
|
17
|
+
prepared = new Map();
|
|
16
18
|
accepted = new Set();
|
|
17
19
|
persistence;
|
|
18
20
|
sync;
|
|
@@ -26,6 +28,8 @@ export class ReadingClient {
|
|
|
26
28
|
generation = 0;
|
|
27
29
|
constructor(options) {
|
|
28
30
|
this.options = options;
|
|
31
|
+
if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
|
|
32
|
+
throw new Error("INVALID_READ_TEAM");
|
|
29
33
|
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
|
|
30
34
|
this.backing = storage;
|
|
31
35
|
this.persistence = new BatchedReadIndex(storage);
|
|
@@ -94,6 +98,11 @@ export class ReadingClient {
|
|
|
94
98
|
throw new Error("Reading projection has not reached the announced version");
|
|
95
99
|
}
|
|
96
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) {
|
|
97
106
|
if (this.closed)
|
|
98
107
|
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
99
108
|
// One shared metadata read per local authority generation, rather than one
|
|
@@ -102,7 +111,7 @@ export class ReadingClient {
|
|
|
102
111
|
const task = (async () => {
|
|
103
112
|
const expected = await versions;
|
|
104
113
|
const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
|
|
105
|
-
assertReadIndexVersions(expected, currentVersions);
|
|
114
|
+
assertReadIndexVersions(expected, currentVersions, teamId);
|
|
106
115
|
return transform(state);
|
|
107
116
|
});
|
|
108
117
|
if (!this.closed) {
|
|
@@ -111,28 +120,48 @@ export class ReadingClient {
|
|
|
111
120
|
this.refresh();
|
|
112
121
|
}
|
|
113
122
|
return next;
|
|
114
|
-
})().catch(error => {
|
|
115
|
-
|
|
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
|
+
});
|
|
116
132
|
this.accepted.add(task);
|
|
117
133
|
void task.finally(() => this.accepted.delete(task)).catch(() => { });
|
|
118
134
|
return task;
|
|
119
135
|
}
|
|
120
|
-
|
|
136
|
+
prepareTeam(teamId, version) {
|
|
121
137
|
if (this.closed)
|
|
122
|
-
|
|
123
|
-
if (version
|
|
124
|
-
|
|
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);
|
|
125
147
|
this.versions = undefined;
|
|
126
|
-
}
|
|
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");
|
|
127
156
|
}
|
|
128
157
|
markRead(threadId, messages = []) {
|
|
129
|
-
return this.
|
|
158
|
+
return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages); }, this.options.teamId);
|
|
130
159
|
}
|
|
131
160
|
recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
|
|
132
161
|
recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
|
|
133
162
|
record(threadId, ids, readPolicy) {
|
|
134
163
|
const messageIds = [...new Set(ids)];
|
|
135
|
-
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);
|
|
136
165
|
}
|
|
137
166
|
withdraw() { this.stop(); }
|
|
138
167
|
stop() {
|
package/package.json
CHANGED