@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.50
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 +34 -1
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +77 -13
- package/lib/runtime/chatSession.js +483 -42
- 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 +5 -5
- package/lib/runtime/createChat.js +16 -17
- package/lib/runtime/durableSender.d.ts +54 -14
- package/lib/runtime/durableSender.js +354 -44
- package/lib/runtime/index.d.ts +4 -0
- package/lib/runtime/index.js +4 -0
- package/lib/runtime/messageClient.d.ts +32 -8
- package/lib/runtime/messageClient.js +415 -75
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +42 -0
- package/lib/runtime/messageLaunch.js +133 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +40 -2
- package/lib/runtime/messageStore.js +218 -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 +55 -6
- package/lib/runtime/readIndex.js +363 -14
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +25 -8
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- 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/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +20 -0
- package/package.json +6 -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) {
|
|
@@ -86,12 +105,12 @@ export function recordReadIntent(current, intent) {
|
|
|
86
105
|
throw new Error("INVALID_READ_INTENT");
|
|
87
106
|
const observed = current.observedReadIntents ?? [];
|
|
88
107
|
if (observed.some(item => item.threadId === intent.threadId && item.messageId === intent.messageId && item.readPolicy === intent.readPolicy))
|
|
89
|
-
return current;
|
|
108
|
+
return resolveObservedReads(current);
|
|
90
109
|
if (observed.some(item => item.operationId === intent.operationId) || current.pending.some(item => item.operationId === intent.operationId))
|
|
91
110
|
throw new Error("INVALID_READ_OPERATION_ID");
|
|
92
111
|
return resolveObservedReads({ ...current, observedReadIntents: [...observed, structuredClone(intent)] });
|
|
93
112
|
}
|
|
94
|
-
function resolveObservedReads(current) {
|
|
113
|
+
export function resolveObservedReads(current) {
|
|
95
114
|
let next = current;
|
|
96
115
|
const remaining = [];
|
|
97
116
|
for (const intent of current.observedReadIntents ?? []) {
|
|
@@ -108,6 +127,9 @@ function resolveObservedReads(current) {
|
|
|
108
127
|
next = intent.readPolicy === "explicit" ? recordExplicitRead(next, intent.threadId, intent.messageId, intent.operationId)
|
|
109
128
|
: recordVisibleRead(next, intent.threadId, intent.messageId, intent.operationId);
|
|
110
129
|
}
|
|
130
|
+
const previous = current.observedReadIntents;
|
|
131
|
+
if (next === current && previous && previous.length === remaining.length && previous.every((intent, i) => intent === remaining[i]))
|
|
132
|
+
return current;
|
|
111
133
|
return { ...next, observedReadIntents: remaining };
|
|
112
134
|
}
|
|
113
135
|
/** Called only for a message the visible viewport actually reached. */
|
|
@@ -153,31 +175,73 @@ export function coalesceReadOperations(current) {
|
|
|
153
175
|
return [...batches.values()];
|
|
154
176
|
}
|
|
155
177
|
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
156
|
-
export function unreadInThread(state, threadId) {
|
|
178
|
+
export function unreadInThread(state, threadId, hidden) {
|
|
157
179
|
const thread = state.threads.find(t => t.id === threadId);
|
|
158
180
|
if (!thread)
|
|
159
181
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
160
182
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
161
|
-
const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
183
|
+
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
184
|
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
|
|
163
185
|
const unreadMessageCount = messages.length;
|
|
164
186
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
165
187
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
166
188
|
}
|
|
189
|
+
function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
|
|
190
|
+
export function assertReadIndexVersions(expected, current = {}, teamId) {
|
|
191
|
+
const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
|
|
192
|
+
if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
|
|
193
|
+
throw new Error("READ_SCOPE_CHANGED");
|
|
194
|
+
}
|
|
195
|
+
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
196
|
+
* queued observations. Other teams' pending operations must survive. */
|
|
197
|
+
export function removeReadTeam(state, teamId, knownThreadIds = []) {
|
|
198
|
+
const removed = new Set(knownThreadIds);
|
|
199
|
+
for (const thread of state.threads)
|
|
200
|
+
if (thread.teamId === teamId) {
|
|
201
|
+
removed.add(thread.id);
|
|
202
|
+
if (thread.conversationId)
|
|
203
|
+
removed.add(thread.conversationId);
|
|
204
|
+
}
|
|
205
|
+
return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
|
|
206
|
+
pending: state.pending.filter(operation => !removed.has(operation.threadId)),
|
|
207
|
+
observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
|
|
208
|
+
}
|
|
167
209
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
168
210
|
export class IndexedDbReadIndex {
|
|
169
211
|
storageScope;
|
|
212
|
+
migrateLegacy;
|
|
213
|
+
metadataOnly;
|
|
170
214
|
database;
|
|
171
215
|
profiles;
|
|
216
|
+
threadRows;
|
|
217
|
+
messageRows;
|
|
218
|
+
positionRows;
|
|
219
|
+
normalizedStates = new WeakSet();
|
|
220
|
+
observationMessages = new WeakMap();
|
|
221
|
+
versions;
|
|
172
222
|
closed = false;
|
|
173
|
-
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
|
|
223
|
+
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
|
|
174
224
|
this.storageScope = storageScope;
|
|
225
|
+
this.migrateLegacy = migrateLegacy;
|
|
226
|
+
this.metadataOnly = metadataOnly;
|
|
175
227
|
if (!factory)
|
|
176
228
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
177
229
|
this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
178
230
|
// The empty schema retains the legacy out-of-line primary key and records.
|
|
179
231
|
this.database.version(1).stores({ profiles: "" });
|
|
232
|
+
this.database.version(2).stores({ profiles: "", versions: "" });
|
|
233
|
+
this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
|
|
234
|
+
// Native indexes track scalar source identity even when an older writer
|
|
235
|
+
// changes rows. Reading these keys does not deserialize messageIds arrays.
|
|
236
|
+
this.database.version(4).stores({ readThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId]" });
|
|
237
|
+
// Keep v4 indexes for already-open older SDKs, and make source discovery
|
|
238
|
+
// dense for legitimate legacy rows whose optional team field is absent.
|
|
239
|
+
this.database.version(5).stores({ readThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId],[owner+id+value.kind],[owner+value.conversationId+value.kind+id]" });
|
|
240
|
+
this.positionRows = this.database.table("readPositions");
|
|
241
|
+
this.threadRows = this.database.table("readThreads");
|
|
242
|
+
this.messageRows = this.database.table("readMessages");
|
|
180
243
|
this.profiles = this.database.table("profiles");
|
|
244
|
+
this.versions = this.database.table("versions");
|
|
181
245
|
this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
|
|
182
246
|
}
|
|
183
247
|
async transaction(mode, execute) {
|
|
@@ -186,7 +250,7 @@ export class IndexedDbReadIndex {
|
|
|
186
250
|
throw new Error("READ_STORAGE_CLOSED");
|
|
187
251
|
let started = false;
|
|
188
252
|
try {
|
|
189
|
-
return await this.database.transaction(mode, this.profiles, async () => {
|
|
253
|
+
return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
|
|
190
254
|
if (this.closed)
|
|
191
255
|
throw new Error("READ_STORAGE_CLOSED");
|
|
192
256
|
started = true;
|
|
@@ -205,17 +269,302 @@ export class IndexedDbReadIndex {
|
|
|
205
269
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
206
270
|
}
|
|
207
271
|
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
272
|
+
async hydrate(current, key) {
|
|
273
|
+
if (current.normalized) {
|
|
274
|
+
const owner = JSON.stringify(key);
|
|
275
|
+
const [threads, messages, positions] = await Promise.all([
|
|
276
|
+
this.threadRows.where("owner").equals(owner).toArray(),
|
|
277
|
+
this.messageRows.where("owner").equals(owner).toArray(),
|
|
278
|
+
this.positionRows.where("owner").equals(owner).toArray(),
|
|
279
|
+
]);
|
|
280
|
+
const byThread = new Map();
|
|
281
|
+
for (const row of messages) {
|
|
282
|
+
if (!byThread.has(row.thread))
|
|
283
|
+
byThread.set(row.thread, new Map());
|
|
284
|
+
byThread.get(row.thread).set(row.id, row.value);
|
|
285
|
+
}
|
|
286
|
+
const byPosition = new Map(positions.map(row => [row.id, row]));
|
|
287
|
+
const byId = new Map(threads.map(row => [row.id, row]));
|
|
288
|
+
const { normalized: _normalized, threadOrder, ...state } = current;
|
|
289
|
+
const result = { ...state, threads: (threadOrder ?? []).map(id => {
|
|
290
|
+
const row = byId.get(id);
|
|
291
|
+
const position = byPosition.get(id);
|
|
292
|
+
if (!row || !position)
|
|
293
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
294
|
+
return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
|
|
295
|
+
const message = byThread.get(id)?.get(messageId);
|
|
296
|
+
if (!message)
|
|
297
|
+
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
298
|
+
return message;
|
|
299
|
+
}) };
|
|
300
|
+
}) };
|
|
301
|
+
this.normalizedStates.add(result);
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
return current;
|
|
305
|
+
}
|
|
306
|
+
async loadCurrent(appId, profileId) {
|
|
307
|
+
const current = await this.loadStored(appId, profileId);
|
|
308
|
+
if (!current || !this.metadataOnly)
|
|
309
|
+
return current;
|
|
310
|
+
const metadata = readingMetadata(current);
|
|
311
|
+
if (metadata !== current)
|
|
312
|
+
await this.persist(appId, profileId, current, metadata);
|
|
313
|
+
return metadata;
|
|
314
|
+
}
|
|
315
|
+
async loadStored(appId, profileId) {
|
|
316
|
+
const current = await this.profiles.get(this.key(appId, profileId));
|
|
317
|
+
if (current?.normalized)
|
|
318
|
+
return this.hydrate(current, this.key(appId, profileId));
|
|
319
|
+
if (current) {
|
|
320
|
+
const next = this.metadataOnly ? readingMetadata(current) : current;
|
|
321
|
+
if (this.migrateLegacy || this.metadataOnly)
|
|
322
|
+
await this.persist(appId, profileId, undefined, next, true);
|
|
323
|
+
return next;
|
|
324
|
+
}
|
|
325
|
+
if (!this.migrateLegacy || this.storageScope === undefined)
|
|
326
|
+
return undefined;
|
|
327
|
+
const markerKey = [appId, profileId, "legacy-owner-v1"];
|
|
328
|
+
const marker = await this.profiles.get(markerKey);
|
|
329
|
+
if (marker && marker.legacyOwner !== this.storageScope)
|
|
330
|
+
return undefined;
|
|
331
|
+
const legacyRow = await this.profiles.get([appId, profileId]);
|
|
332
|
+
if (!legacyRow)
|
|
333
|
+
return undefined;
|
|
334
|
+
const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
|
|
335
|
+
const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
|
|
336
|
+
if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
|
|
337
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
338
|
+
const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
|
|
339
|
+
await this.profiles.put(ownership, markerKey);
|
|
340
|
+
await this.persist(appId, profileId, undefined, legacy);
|
|
341
|
+
await this.profiles.delete([appId, profileId]);
|
|
342
|
+
const oldOwner = JSON.stringify([appId, profileId]);
|
|
343
|
+
await this.threadRows.where("owner").equals(oldOwner).delete();
|
|
344
|
+
await this.messageRows.where("owner").equals(oldOwner).delete();
|
|
345
|
+
await this.positionRows.where("owner").equals(oldOwner).delete();
|
|
346
|
+
return legacy;
|
|
347
|
+
}
|
|
348
|
+
/** Message bodies are immutable for local read observations: keep them out of
|
|
349
|
+
* profile/cursor writes, and update only changed normalized rows on sync. */
|
|
350
|
+
async persist(appId, profileId, previous, next, replaceLegacy = false) {
|
|
351
|
+
if (previous && !this.normalizedStates.has(previous)) {
|
|
352
|
+
previous = undefined;
|
|
353
|
+
replaceLegacy = true;
|
|
354
|
+
}
|
|
355
|
+
const owner = JSON.stringify(this.key(appId, profileId));
|
|
356
|
+
const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
|
|
357
|
+
const nextIds = new Set(next.threads.map(thread => thread.id));
|
|
358
|
+
if (nextIds.size !== next.threads.length)
|
|
359
|
+
throw new Error("DUPLICATE_READ_THREAD");
|
|
360
|
+
const threadWrites = [], messageWrites = [], positionWrites = [];
|
|
361
|
+
const messageDeletes = [];
|
|
362
|
+
for (const thread of next.threads) {
|
|
363
|
+
const old = oldThreads.get(thread.id);
|
|
364
|
+
if (old === thread)
|
|
365
|
+
continue;
|
|
366
|
+
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
367
|
+
const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
|
|
368
|
+
if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
369
|
+
positionWrites.push(position);
|
|
370
|
+
const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
|
|
371
|
+
if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
|
|
372
|
+
const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
|
|
373
|
+
const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
|
|
374
|
+
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
375
|
+
threadWrites.push(metadata);
|
|
376
|
+
}
|
|
377
|
+
if (old?.messages === messages)
|
|
378
|
+
continue;
|
|
379
|
+
const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
|
|
380
|
+
const ids = new Set();
|
|
381
|
+
for (const message of messages) {
|
|
382
|
+
if (ids.has(message.id))
|
|
383
|
+
throw new Error("INVALID_READ_MESSAGE");
|
|
384
|
+
ids.add(message.id);
|
|
385
|
+
const prior = byId.get(message.id);
|
|
386
|
+
if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
|
|
387
|
+
messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
|
|
388
|
+
}
|
|
389
|
+
for (const id of byId.keys())
|
|
390
|
+
if (!ids.has(id))
|
|
391
|
+
messageDeletes.push([owner, thread.id, id]);
|
|
392
|
+
}
|
|
393
|
+
for (const id of oldThreads.keys())
|
|
394
|
+
if (!nextIds.has(id)) {
|
|
395
|
+
await this.threadRows.delete([owner, id]);
|
|
396
|
+
await this.positionRows.delete([owner, id]);
|
|
397
|
+
await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
|
|
398
|
+
}
|
|
399
|
+
// A legacy document can replace the full profile after normalization. Its
|
|
400
|
+
// replacement is authoritative for the row set; discard unreachable bodies
|
|
401
|
+
// in the same transaction before installing the rebuilt normalized records.
|
|
402
|
+
if (replaceLegacy) {
|
|
403
|
+
await this.messageRows.where("owner").equals(owner).delete();
|
|
404
|
+
await this.threadRows.where("owner").equals(owner).delete();
|
|
405
|
+
await this.positionRows.where("owner").equals(owner).delete();
|
|
406
|
+
}
|
|
407
|
+
// Bound each IndexedDB bulk request. A snapshot and its cursor still commit
|
|
408
|
+
// atomically; bounding total snapshot transaction bytes is separate work.
|
|
409
|
+
for (let offset = 0; offset < positionWrites.length; offset += 128)
|
|
410
|
+
await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
|
|
411
|
+
for (let offset = 0; offset < threadWrites.length; offset += 128)
|
|
412
|
+
await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
|
|
413
|
+
for (let offset = 0; offset < messageWrites.length; offset += 128)
|
|
414
|
+
await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
|
|
415
|
+
for (let offset = 0; offset < messageDeletes.length; offset += 128)
|
|
416
|
+
await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
|
|
417
|
+
const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
|
|
418
|
+
const nextHeader = header(next);
|
|
419
|
+
if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
|
|
420
|
+
await this.profiles.put(nextHeader, this.key(appId, profileId));
|
|
421
|
+
this.normalizedStates.add(next);
|
|
422
|
+
}
|
|
208
423
|
async load(appId, profileId) {
|
|
209
|
-
return this.transaction("r", () => this.
|
|
424
|
+
return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", () => this.loadCurrent(appId, profileId));
|
|
425
|
+
}
|
|
426
|
+
async loadVersioned(appId, profileId) {
|
|
427
|
+
return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", async () => ({
|
|
428
|
+
state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
|
|
429
|
+
}));
|
|
210
430
|
}
|
|
211
|
-
async
|
|
431
|
+
async loadVersions(appId, profileId) {
|
|
432
|
+
return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
433
|
+
}
|
|
434
|
+
async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
|
|
435
|
+
if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
|
|
436
|
+
throw new Error("INVALID_READ_SCOPE_VERSION");
|
|
212
437
|
return this.transaction("rw", async () => {
|
|
213
|
-
const
|
|
214
|
-
const
|
|
438
|
+
const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
|
|
439
|
+
const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
|
|
440
|
+
if (readTeamVersion(teams, teamId) >= version)
|
|
441
|
+
return state;
|
|
442
|
+
const next = removeReadTeam(state, teamId, knownThreadIds);
|
|
443
|
+
await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
|
|
444
|
+
await this.persist(appId, profileId, state, next);
|
|
445
|
+
return next;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
async observe(appId, profileId, transform, scope) {
|
|
449
|
+
if (!this.metadataOnly || scope.targets.length > 128)
|
|
450
|
+
return undefined;
|
|
451
|
+
const key = this.key(appId, profileId), owner = JSON.stringify(key), base = scope.base;
|
|
452
|
+
const header = await this.profiles.get(key);
|
|
453
|
+
if (header && (header.appId !== appId || header.profileId !== profileId || header.schemaVersion !== 1))
|
|
454
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
455
|
+
if (!header?.normalized || base.schemaVersion !== 1 || base.appId !== appId || base.profileId !== profileId || base.syncCursor !== header.syncCursor ||
|
|
456
|
+
header.threadOrder?.length !== base.threads.length || header.threadOrder.some((id, i) => id !== base.threads[i].id))
|
|
457
|
+
return undefined;
|
|
458
|
+
const sources = new Map();
|
|
459
|
+
for (const threadId of new Set(scope.targets.map(target => target.threadId))) {
|
|
460
|
+
const direct = await this.threadRows.where("[owner+id+value.kind]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
|
|
461
|
+
const assigned = await this.threadRows.where("[owner+value.conversationId+value.kind+id]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
|
|
462
|
+
if (direct.length + assigned.length > 128)
|
|
463
|
+
return undefined;
|
|
464
|
+
const identities = [...new Map([...direct.map(value => ({ id: value[1], kind: value[2] })), ...assigned.map(value => ({ id: value[3], kind: value[2] }))].map(value => [value.id, value])).values()];
|
|
465
|
+
const indexedIds = new Set(identities.map(identity => identity.id));
|
|
466
|
+
const expected = base.threads.filter(thread => thread.id === threadId || thread.conversationId === threadId);
|
|
467
|
+
// Sparse indexes omit legacy sources without team metadata. A partial
|
|
468
|
+
// family cannot safely resolve an observation for the logical thread.
|
|
469
|
+
if (indexedIds.size !== expected.length || expected.some(thread => !indexedIds.has(thread.id)))
|
|
470
|
+
return undefined;
|
|
471
|
+
for (const identity of identities) {
|
|
472
|
+
const cached = base.threads.find(thread => thread.id === identity.id);
|
|
473
|
+
if (!cached || cached.kind !== identity.kind)
|
|
474
|
+
return undefined;
|
|
475
|
+
const team = await this.threadRows.where("[owner+id+value.kind+value.teamId]").between([owner, identity.id, Dexie.minKey], [owner, identity.id, Dexie.maxKey], true, true).limit(2).keys();
|
|
476
|
+
if (team.length > 1 || cached.teamId !== team[0]?.[3])
|
|
477
|
+
return undefined;
|
|
478
|
+
const mapping = await this.threadRows.where("[owner+id+value.conversationId]").between([owner, identity.id, Dexie.minKey], [owner, identity.id, Dexie.maxKey], true, true).limit(2).keys();
|
|
479
|
+
if (mapping.length > 1 || cached.conversationId !== mapping[0]?.[2])
|
|
480
|
+
return undefined;
|
|
481
|
+
let source = sources.get(identity.id);
|
|
482
|
+
if (!source) {
|
|
483
|
+
source = { cached, ids: new Set() };
|
|
484
|
+
sources.set(identity.id, source);
|
|
485
|
+
}
|
|
486
|
+
for (const target of scope.targets)
|
|
487
|
+
if (target.threadId === threadId)
|
|
488
|
+
source.ids.add(target.messageId);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if ([...sources.values()].reduce((sum, source) => sum + source.ids.size, 0) > 128)
|
|
492
|
+
return undefined;
|
|
493
|
+
const sourceIds = [...sources.keys()];
|
|
494
|
+
// Cursors are the mutable cross-document view. Refresh their small rows,
|
|
495
|
+
// while message metadata remains targeted; otherwise a later batch based
|
|
496
|
+
// on an older snapshot could regress a different conversation's progress.
|
|
497
|
+
const allPositions = await this.positionRows.where("owner").equals(owner).toArray();
|
|
498
|
+
const positionById = new Map(allPositions.map(position => [position.id, position]));
|
|
499
|
+
if (base.threads.some(thread => !positionById.has(thread.id)))
|
|
500
|
+
return undefined;
|
|
501
|
+
const positions = sourceIds.map(id => positionById.get(id));
|
|
502
|
+
const partialThreads = [];
|
|
503
|
+
for (let i = 0; i < sourceIds.length; i++) {
|
|
504
|
+
const id = sourceIds[i], source = sources.get(id), position = positions[i];
|
|
505
|
+
if (!position)
|
|
506
|
+
return undefined;
|
|
507
|
+
let cached = this.observationMessages.get(source.cached.messages);
|
|
508
|
+
if (!cached) {
|
|
509
|
+
cached = new Map(source.cached.messages.map(message => [message.id, message]));
|
|
510
|
+
this.observationMessages.set(source.cached.messages, cached);
|
|
511
|
+
}
|
|
512
|
+
const ids = [...source.ids], rows = await this.messageRows.bulkGet(ids.map(messageId => [owner, id, messageId]));
|
|
513
|
+
const messages = [];
|
|
514
|
+
for (let j = 0; j < ids.length; j++) {
|
|
515
|
+
const previous = cached.get(ids[j]), message = rows[j]?.value;
|
|
516
|
+
if (message && (message.payload !== null || typeof message.readingControl !== "boolean"))
|
|
517
|
+
return undefined;
|
|
518
|
+
if (JSON.stringify(previous) !== JSON.stringify(message))
|
|
519
|
+
return undefined;
|
|
520
|
+
if (message)
|
|
521
|
+
messages.push(previous);
|
|
522
|
+
}
|
|
523
|
+
partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds });
|
|
524
|
+
}
|
|
525
|
+
const partial = { schemaVersion: 1, appId, profileId, syncCursor: header.syncCursor, pending: header.pending, observedReadIntents: header.observedReadIntents, threads: partialThreads };
|
|
526
|
+
const result = transform(partial, (await this.versions.get(key))?.teams ?? {});
|
|
527
|
+
if (result.appId !== appId || result.profileId !== profileId || result.schemaVersion !== 1)
|
|
528
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
529
|
+
if (result.syncCursor !== partial.syncCursor || result.threads.length !== partialThreads.length || result.threads.some((thread, i) => thread.id !== partialThreads[i].id || thread.kind !== partialThreads[i].kind || thread.teamId !== partialThreads[i].teamId || thread.conversationId !== partialThreads[i].conversationId || thread.messages !== partialThreads[i].messages))
|
|
530
|
+
return undefined;
|
|
531
|
+
const writes = [];
|
|
532
|
+
for (let i = 0; i < result.threads.length; i++) {
|
|
533
|
+
const thread = result.threads[i], before = positions[i];
|
|
534
|
+
if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
|
|
535
|
+
writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
|
|
536
|
+
}
|
|
537
|
+
if (writes.length)
|
|
538
|
+
await this.positionRows.bulkPut(writes);
|
|
539
|
+
if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
|
|
540
|
+
await this.profiles.put({ ...header, pending: result.pending, observedReadIntents: result.observedReadIntents }, key);
|
|
541
|
+
const byId = new Map(allPositions.map(position => [position.id, position]));
|
|
542
|
+
result.threads.forEach(thread => byId.set(thread.id, thread));
|
|
543
|
+
let changed = false;
|
|
544
|
+
const threads = base.threads.map(thread => {
|
|
545
|
+
const position = byId.get(thread.id);
|
|
546
|
+
if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
|
|
547
|
+
return thread;
|
|
548
|
+
changed = true;
|
|
549
|
+
return { ...thread, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds };
|
|
550
|
+
});
|
|
551
|
+
if (!changed && JSON.stringify(base.pending) === JSON.stringify(result.pending) && JSON.stringify(base.observedReadIntents) === JSON.stringify(result.observedReadIntents))
|
|
552
|
+
return base;
|
|
553
|
+
return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
|
|
554
|
+
}
|
|
555
|
+
async update(appId, profileId, transform, observation) {
|
|
556
|
+
return this.transaction("rw", async () => {
|
|
557
|
+
if (observation) {
|
|
558
|
+
const next = await this.observe(appId, profileId, transform, observation);
|
|
559
|
+
if (next)
|
|
560
|
+
return next;
|
|
561
|
+
}
|
|
562
|
+
const previous = await this.loadCurrent(appId, profileId);
|
|
563
|
+
const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
564
|
+
const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
|
|
215
565
|
if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
|
|
216
566
|
throw new Error("READ_IDENTITY_MISMATCH");
|
|
217
|
-
|
|
218
|
-
await this.profiles.put(next, this.key(appId, profileId));
|
|
567
|
+
await this.persist(appId, profileId, previous, next);
|
|
219
568
|
return next;
|
|
220
569
|
});
|
|
221
570
|
}
|
|
@@ -231,14 +580,14 @@ function readSource(state, conversationId, messageId) {
|
|
|
231
580
|
return sources[0];
|
|
232
581
|
}
|
|
233
582
|
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
234
|
-
export function unreadInConversation(state, conversationId) {
|
|
583
|
+
export function unreadInConversation(state, conversationId, hidden) {
|
|
235
584
|
const sources = sourcesInConversation(state, conversationId);
|
|
236
585
|
if (!sources.length)
|
|
237
586
|
throw new Error("UNKNOWN_READ_CONVERSATION");
|
|
238
587
|
if (new Set(sources.map(source => source.teamId)).size > 1)
|
|
239
588
|
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
240
589
|
return sources.reduce((total, source) => {
|
|
241
|
-
const unread = unreadInThread(state, source.id);
|
|
590
|
+
const unread = unreadInThread(state, source.id, hidden);
|
|
242
591
|
return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
|
|
243
592
|
}, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
|
|
244
593
|
}
|
package/lib/runtime/readSync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
|
|
1
|
+
import { readingMetadata, resolveObservedReads, 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,15 @@ 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
|
+
const normalize = (current) => {
|
|
15
|
+
const next = metadataOnly ? readingMetadata(current) : current;
|
|
16
|
+
return next.observedReadIntents?.length ? resolveObservedReads(next) : next;
|
|
17
|
+
};
|
|
18
|
+
if (normalize(state) === state)
|
|
19
|
+
return state;
|
|
20
|
+
return persistence.update(appId, profileId, normalize);
|
|
14
21
|
}
|
|
15
22
|
stop() { this.controller.abort(); }
|
|
16
23
|
synchronize() {
|
|
@@ -46,7 +53,9 @@ export class ReadSyncClient {
|
|
|
46
53
|
}
|
|
47
54
|
async run() {
|
|
48
55
|
const { appId, profileId, persistence } = this.options;
|
|
49
|
-
const
|
|
56
|
+
const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
|
|
57
|
+
const current = loaded.state ?? createReadIndex(appId, profileId);
|
|
58
|
+
const versions = loaded.versions;
|
|
50
59
|
// Ordinary reads are monotonic: only the furthest position per thread needs
|
|
51
60
|
// sending. Keep every original operation in storage until the projection
|
|
52
61
|
// confirms it, and never rewrite an operation ID's payload during retries.
|
|
@@ -60,6 +69,8 @@ export class ReadSyncClient {
|
|
|
60
69
|
}
|
|
61
70
|
const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
|
|
62
71
|
for (const operation of outgoing) {
|
|
72
|
+
if (persistence.loadVersions)
|
|
73
|
+
assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
|
|
63
74
|
const receipt = await this.request("/api/social/reading", {
|
|
64
75
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
|
|
65
76
|
});
|
|
@@ -69,8 +80,10 @@ export class ReadSyncClient {
|
|
|
69
80
|
this.readAfter = Math.max(this.readAfter, receipt.acceptedAt);
|
|
70
81
|
}
|
|
71
82
|
const query = new URLSearchParams({ appId, profileId });
|
|
83
|
+
if (this.options.metadataOnly)
|
|
84
|
+
query.set("metadataOnly", "true");
|
|
72
85
|
if (!this.readAfter && current.syncCursor !== "0") {
|
|
73
|
-
const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor })}`);
|
|
86
|
+
const delta = await this.request(`/api/social/reading/changes?${new URLSearchParams({ appId, profileId, after: current.syncCursor, ...(this.options.metadataOnly ? { metadataOnly: "true" } : {}) })}`);
|
|
74
87
|
if (delta.appId !== appId || delta.profileId !== profileId || typeof delta.syncCursor !== "string"
|
|
75
88
|
|| typeof delta.snapshotRequired !== "boolean" || !Array.isArray(delta.changes))
|
|
76
89
|
throw new Error("INVALID_READ_DELTA");
|
|
@@ -78,8 +91,9 @@ export class ReadSyncClient {
|
|
|
78
91
|
try {
|
|
79
92
|
if (this.controller.signal.aborted)
|
|
80
93
|
throw new Error("READ_SYNC_STOPPED");
|
|
81
|
-
const next = await persistence.update(appId, profileId, state => {
|
|
82
|
-
|
|
94
|
+
const next = await persistence.update(appId, profileId, (state, currentVersions) => {
|
|
95
|
+
assertReadIndexVersions(versions, currentVersions);
|
|
96
|
+
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
97
|
if (applied.syncCursor !== delta.syncCursor)
|
|
84
98
|
throw new ReadSyncGap(applied.syncCursor);
|
|
85
99
|
return applied;
|
|
@@ -100,7 +114,10 @@ export class ReadSyncClient {
|
|
|
100
114
|
if (this.controller.signal.aborted)
|
|
101
115
|
throw new Error("READ_SYNC_STOPPED");
|
|
102
116
|
// Merge into the latest transaction, including reads made while HTTP was pending.
|
|
103
|
-
const next = await persistence.update(appId, profileId,
|
|
117
|
+
const next = await persistence.update(appId, profileId, (state, currentVersions) => {
|
|
118
|
+
assertReadIndexVersions(versions, currentVersions);
|
|
119
|
+
return installReadSnapshot(state, this.options.metadataOnly ? readingMetadata(snapshot) : snapshot);
|
|
120
|
+
});
|
|
104
121
|
if (!next.pending.length)
|
|
105
122
|
this.readAfter = 0;
|
|
106
123
|
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
|
}
|