@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.40
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/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 +3 -0
- package/lib/runtime/createChat.js +18 -5
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +155 -34
- package/lib/runtime/index.d.ts +3 -0
- package/lib/runtime/index.js +3 -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/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +41 -2
- package/lib/runtime/messageStore.js +212 -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 +1 -1
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
|
}
|
package/lib/runtime/readSync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
1
|
+
import { readingMetadata, assertReadIndexVersions, applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
2
2
|
/** Keeps local operations until an authoritative projection covers them. */
|
|
3
3
|
export class ReadSyncClient {
|
|
4
4
|
options;
|
|
@@ -9,8 +9,11 @@ export class ReadSyncClient {
|
|
|
9
9
|
this.options = options;
|
|
10
10
|
}
|
|
11
11
|
async loadLocal() {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const { appId, profileId, persistence, metadataOnly } = this.options;
|
|
13
|
+
const state = await persistence.load(appId, profileId) ?? createReadIndex(appId, profileId);
|
|
14
|
+
if (!metadataOnly || readingMetadata(state) === state)
|
|
15
|
+
return state;
|
|
16
|
+
return persistence.update(appId, profileId, readingMetadata);
|
|
14
17
|
}
|
|
15
18
|
stop() { this.controller.abort(); }
|
|
16
19
|
synchronize() {
|
|
@@ -46,7 +49,9 @@ export class ReadSyncClient {
|
|
|
46
49
|
}
|
|
47
50
|
async run() {
|
|
48
51
|
const { appId, profileId, persistence } = this.options;
|
|
49
|
-
const
|
|
52
|
+
const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
|
|
53
|
+
const current = loaded.state ?? createReadIndex(appId, profileId);
|
|
54
|
+
const versions = loaded.versions;
|
|
50
55
|
// Ordinary reads are monotonic: only the furthest position per thread needs
|
|
51
56
|
// sending. Keep every original operation in storage until the projection
|
|
52
57
|
// confirms it, and never rewrite an operation ID's payload during retries.
|
|
@@ -60,6 +65,8 @@ export class ReadSyncClient {
|
|
|
60
65
|
}
|
|
61
66
|
const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
|
|
62
67
|
for (const operation of outgoing) {
|
|
68
|
+
if (persistence.loadVersions)
|
|
69
|
+
assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
|
|
63
70
|
const receipt = await this.request("/api/social/reading", {
|
|
64
71
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
|
|
65
72
|
});
|
|
@@ -69,8 +76,10 @@ export class ReadSyncClient {
|
|
|
69
76
|
this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
|
|
70
77
|
}
|
|
71
78
|
const query = new URLSearchParams({ appId, profileId });
|
|
79
|
+
if (this.options.metadataOnly)
|
|
80
|
+
query.set("metadataOnly", "true");
|
|
72
81
|
if (!this.readAfter && current.syncCursor !== "0") {
|
|
73
|
-
const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor })}`);
|
|
82
|
+
const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor, ...(this.options.metadataOnly ? { metadataOnly: "true" } : {}) })}`);
|
|
74
83
|
if (delta.appId !== appId || delta.profileId !== profileId || typeof delta.syncCursor !== "string"
|
|
75
84
|
|| typeof delta.snapshotRequired !== "boolean" || !Array.isArray(delta.changes))
|
|
76
85
|
throw new Error("INVALID_READ_DELTA");
|
|
@@ -78,8 +87,9 @@ export class ReadSyncClient {
|
|
|
78
87
|
try {
|
|
79
88
|
if (this.controller.signal.aborted)
|
|
80
89
|
throw new Error("READ_SYNC_STOPPED");
|
|
81
|
-
const next = await persistence.update(appId, profileId, state => {
|
|
82
|
-
|
|
90
|
+
const next = await persistence.update(appId, profileId, (state, currentVersions) => {
|
|
91
|
+
assertReadIndexVersions(versions, currentVersions);
|
|
92
|
+
const applied = delta.changes.reduce((current, change) => applyReadChange(current, this.options.metadataOnly ? { ...change, thread: readingMetadata({ appId, threads: [change.thread] }).threads[0] } : change), this.options.metadataOnly ? readingMetadata(state) : state);
|
|
83
93
|
if (applied.syncCursor !== delta.syncCursor)
|
|
84
94
|
throw new ReadSyncGap(applied.syncCursor);
|
|
85
95
|
return applied;
|
|
@@ -100,7 +110,10 @@ export class ReadSyncClient {
|
|
|
100
110
|
if (this.controller.signal.aborted)
|
|
101
111
|
throw new Error("READ_SYNC_STOPPED");
|
|
102
112
|
// Merge into the latest transaction, including reads made while HTTP was pending.
|
|
103
|
-
const next = await persistence.update(appId, profileId,
|
|
113
|
+
const next = await persistence.update(appId, profileId, (state, currentVersions) => {
|
|
114
|
+
assertReadIndexVersions(versions, currentVersions);
|
|
115
|
+
return installReadSnapshot(state, this.options.metadataOnly ? readingMetadata(snapshot) : snapshot);
|
|
116
|
+
});
|
|
104
117
|
if (!next.pending.length)
|
|
105
118
|
this.readAfter = 0;
|
|
106
119
|
if (!this.controller.signal.aborted)
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { type ConversationReadMessage } from "./conversationRead.js";
|
|
1
2
|
import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
|
|
2
3
|
export type ReadingClientOptions = {
|
|
3
4
|
appId: string;
|
|
4
5
|
profileId: string;
|
|
5
6
|
sessionToken: string;
|
|
6
7
|
hubHttpUrl: string;
|
|
8
|
+
teamId?: string;
|
|
9
|
+
metadataOnly?: boolean;
|
|
7
10
|
persistence?: ReadIndexPersistence;
|
|
8
11
|
storageScope?: string;
|
|
12
|
+
migrateLegacy?: boolean;
|
|
9
13
|
fetch?: typeof fetch;
|
|
10
14
|
onError?: (error: unknown) => void;
|
|
11
15
|
onRecovered?: () => void;
|
|
@@ -15,6 +19,12 @@ export type ReadingClientOptions = {
|
|
|
15
19
|
export declare class ReadingClient {
|
|
16
20
|
private options;
|
|
17
21
|
private storage;
|
|
22
|
+
private backing;
|
|
23
|
+
private closing?;
|
|
24
|
+
private revoking?;
|
|
25
|
+
private versions?;
|
|
26
|
+
private prepared;
|
|
27
|
+
private accepted;
|
|
18
28
|
private persistence;
|
|
19
29
|
private sync;
|
|
20
30
|
private tasks;
|
|
@@ -34,8 +44,17 @@ export declare class ReadingClient {
|
|
|
34
44
|
refresh: () => void;
|
|
35
45
|
synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
|
|
36
46
|
update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
|
|
47
|
+
private updateScoped;
|
|
48
|
+
prepareTeam(teamId: string, version: number): Promise<void>;
|
|
49
|
+
private assertThreadTeam;
|
|
50
|
+
markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
|
|
37
51
|
recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
38
52
|
recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
39
53
|
private record;
|
|
54
|
+
withdraw(): void;
|
|
55
|
+
private stop;
|
|
56
|
+
/** This client is retired permanently. Drain accepted local observations
|
|
57
|
+
* before deleting one team's profile-wide projection; sibling teams survive. */
|
|
58
|
+
revokeTeam(teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<void>;
|
|
40
59
|
close(): Promise<void>;
|
|
41
60
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
import { isValidChatTeamId } from "./thread.js";
|
|
2
|
+
import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
|
|
3
|
+
import { recordConversationRead } from "./conversationRead.js";
|
|
1
4
|
import { BatchedReadIndex } from "./batchedReadIndex.js";
|
|
2
5
|
import { WindowedTasks } from "./coalescer.js";
|
|
3
|
-
import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
|
|
6
|
+
import { IndexedDbReadIndex, readingMetadata, recordReadIntent, assertReadIndexVersions } from "./readIndex.js";
|
|
4
7
|
import { ReadSyncClient } from "./readSync.js";
|
|
5
8
|
/** Owns durable local reads, network coalescing, retries and state notifications.
|
|
6
9
|
* Hosts report actual visibility; loading or subscribing never marks messages read. */
|
|
7
10
|
export class ReadingClient {
|
|
8
11
|
options;
|
|
9
12
|
storage;
|
|
13
|
+
backing;
|
|
14
|
+
closing;
|
|
15
|
+
revoking;
|
|
16
|
+
versions;
|
|
17
|
+
prepared = new Map();
|
|
18
|
+
accepted = new Set();
|
|
10
19
|
persistence;
|
|
11
20
|
sync;
|
|
12
21
|
tasks;
|
|
@@ -19,7 +28,10 @@ export class ReadingClient {
|
|
|
19
28
|
generation = 0;
|
|
20
29
|
constructor(options) {
|
|
21
30
|
this.options = options;
|
|
22
|
-
|
|
31
|
+
if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
|
|
32
|
+
throw new Error("INVALID_READ_TEAM");
|
|
33
|
+
const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy, options.metadataOnly));
|
|
34
|
+
this.backing = storage;
|
|
23
35
|
this.persistence = new BatchedReadIndex(storage);
|
|
24
36
|
this.tasks = new WindowedTasks(1000, error => this.failed(error));
|
|
25
37
|
this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
|
|
@@ -33,7 +45,7 @@ export class ReadingClient {
|
|
|
33
45
|
this.listeners.forEach(listener => listener());
|
|
34
46
|
}
|
|
35
47
|
failed(error) {
|
|
36
|
-
if (this.closed)
|
|
48
|
+
if (this.closed || isOfflineNetworkFailure(error))
|
|
37
49
|
return;
|
|
38
50
|
this.options.onError?.(error);
|
|
39
51
|
if (/HTTP_(400|401|403)/.test(String(error)) || (typeof navigator !== "undefined" && !navigator.onLine) || this.retry)
|
|
@@ -56,8 +68,9 @@ export class ReadingClient {
|
|
|
56
68
|
this.refresh();
|
|
57
69
|
}
|
|
58
70
|
refresh = () => {
|
|
59
|
-
if (!this.closed)
|
|
60
|
-
this.tasks.schedule("reading", async () => {
|
|
71
|
+
if (!this.closed && !isOffline())
|
|
72
|
+
this.tasks.schedule("reading", async () => { if (!isOffline())
|
|
73
|
+
await this.synchronize(); });
|
|
61
74
|
};
|
|
62
75
|
async synchronize(targetCursor, profileId) {
|
|
63
76
|
if (this.closed)
|
|
@@ -84,32 +97,122 @@ export class ReadingClient {
|
|
|
84
97
|
}
|
|
85
98
|
throw new Error("Reading projection has not reached the announced version");
|
|
86
99
|
}
|
|
87
|
-
|
|
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) {
|
|
88
106
|
if (this.closed)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
107
|
+
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
108
|
+
// One shared metadata read per local authority generation, rather than one
|
|
109
|
+
// IndexedDB lookup for every viewport observation.
|
|
110
|
+
const versions = this.versions ??= this.persistence.loadVersions(this.options.appId, this.options.profileId);
|
|
111
|
+
const task = (async () => {
|
|
112
|
+
const expected = await versions;
|
|
113
|
+
const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
|
|
114
|
+
assertReadIndexVersions(expected, currentVersions, teamId);
|
|
115
|
+
const next = transform(state);
|
|
116
|
+
return this.options.metadataOnly ? readingMetadata(next) : next;
|
|
117
|
+
});
|
|
118
|
+
if (!this.closed) {
|
|
119
|
+
this.publish(next);
|
|
120
|
+
if (next.pending.length || next.observedReadIntents?.length)
|
|
121
|
+
this.refresh();
|
|
122
|
+
}
|
|
123
|
+
return next;
|
|
124
|
+
})().catch(error => {
|
|
125
|
+
if (String(error).includes("READ_SCOPE_CHANGED")) {
|
|
126
|
+
if (teamId !== undefined)
|
|
127
|
+
this.withdraw();
|
|
128
|
+
else
|
|
129
|
+
this.versions = undefined;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
});
|
|
133
|
+
this.accepted.add(task);
|
|
134
|
+
void task.finally(() => this.accepted.delete(task)).catch(() => { });
|
|
135
|
+
return task;
|
|
136
|
+
}
|
|
137
|
+
prepareTeam(teamId, version) {
|
|
138
|
+
if (this.closed)
|
|
139
|
+
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
140
|
+
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 0)
|
|
141
|
+
return Promise.reject(new Error("INVALID_READ_SCOPE_VERSION"));
|
|
142
|
+
const previous = this.prepared.get(teamId);
|
|
143
|
+
if (previous && previous.version >= version)
|
|
144
|
+
return previous.task;
|
|
145
|
+
const task = (async () => {
|
|
146
|
+
if (version > 0)
|
|
147
|
+
await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
|
|
148
|
+
this.versions = undefined;
|
|
149
|
+
})().catch(error => { if (this.prepared.get(teamId)?.task === task)
|
|
150
|
+
this.prepared.delete(teamId); throw error; });
|
|
151
|
+
this.prepared.set(teamId, { version, task });
|
|
152
|
+
return task;
|
|
153
|
+
}
|
|
154
|
+
assertThreadTeam(state, threadId) {
|
|
155
|
+
if (this.options.teamId !== undefined && state.threads.some(thread => (thread.id === threadId || thread.conversationId === threadId) && thread.teamId !== undefined && thread.teamId !== this.options.teamId))
|
|
156
|
+
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
157
|
+
}
|
|
158
|
+
markRead(threadId, messages = []) {
|
|
159
|
+
return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages); }, this.options.teamId);
|
|
97
160
|
}
|
|
98
161
|
recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
|
|
99
162
|
recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
|
|
100
163
|
record(threadId, ids, readPolicy) {
|
|
101
164
|
const messageIds = [...new Set(ids)];
|
|
102
|
-
return this.
|
|
165
|
+
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);
|
|
103
166
|
}
|
|
104
|
-
|
|
167
|
+
withdraw() { this.stop(); }
|
|
168
|
+
stop() {
|
|
169
|
+
if (this.closed)
|
|
170
|
+
return;
|
|
105
171
|
this.closed = true;
|
|
106
172
|
this.generation++;
|
|
107
173
|
this.offWake?.();
|
|
108
174
|
this.tasks.close();
|
|
109
175
|
clearTimeout(this.retry);
|
|
110
176
|
this.sync.stop();
|
|
177
|
+
this.state = undefined;
|
|
178
|
+
this.listeners.forEach(listener => listener());
|
|
111
179
|
this.listeners.clear();
|
|
112
|
-
|
|
113
|
-
|
|
180
|
+
}
|
|
181
|
+
/** This client is retired permanently. Drain accepted local observations
|
|
182
|
+
* before deleting one team's profile-wide projection; sibling teams survive. */
|
|
183
|
+
revokeTeam(teamId, version, knownThreadIds = []) {
|
|
184
|
+
if (this.revoking)
|
|
185
|
+
return this.revoking;
|
|
186
|
+
if (this.closing)
|
|
187
|
+
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
188
|
+
this.stop();
|
|
189
|
+
return this.revoking = (async () => {
|
|
190
|
+
try {
|
|
191
|
+
await Promise.allSettled([...this.accepted]);
|
|
192
|
+
await this.persistence.close();
|
|
193
|
+
if (!this.backing.invalidateTeam)
|
|
194
|
+
throw new Error("READ_SCOPE_INVALIDATION_UNAVAILABLE");
|
|
195
|
+
await this.backing.invalidateTeam(this.options.appId, this.options.profileId, teamId, version, knownThreadIds);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await this.storage?.close();
|
|
199
|
+
}
|
|
200
|
+
})();
|
|
201
|
+
}
|
|
202
|
+
close() {
|
|
203
|
+
if (this.revoking)
|
|
204
|
+
return this.revoking;
|
|
205
|
+
if (this.closing)
|
|
206
|
+
return this.closing;
|
|
207
|
+
this.stop();
|
|
208
|
+
return this.closing = (async () => {
|
|
209
|
+
try {
|
|
210
|
+
await Promise.allSettled([...this.accepted]);
|
|
211
|
+
await this.persistence.close();
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await this.storage?.close();
|
|
215
|
+
}
|
|
216
|
+
})();
|
|
114
217
|
}
|
|
115
218
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Social control envelopes do not contribute to message unread counts. */
|
|
2
|
+
export declare function isReadingControl(payload: unknown, appId: string): boolean;
|
|
3
|
+
/** Mirrors the server's voice policy for ordinary and quoted wire envelopes. */
|
|
4
|
+
export declare function messageRequiresPlayback(body: unknown): boolean;
|
|
5
|
+
export declare function messageMentionsProfile(body: unknown, profileId: string): boolean;
|