@pmate/chat-sdk 0.12.0-next.45 → 0.12.0-next.47
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/lib/runtime/batchedReadIndex.d.ts +6 -2
- package/lib/runtime/batchedReadIndex.js +30 -4
- package/lib/runtime/messageClient.d.ts +1 -0
- package/lib/runtime/messageClient.js +16 -3
- package/lib/runtime/readIndex.d.ts +12 -2
- package/lib/runtime/readIndex.js +110 -1
- package/lib/runtime/readingClient.js +13 -4
- package/package.json +1 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { type ReadIndexPersistence, type ReadIndexState, type ReadIndexTransform } from "./readIndex.js";
|
|
1
|
+
import { type ReadIndexPersistence, type ReadIndexState, type ReadIndexTransform, type ReadObservationScope } from "./readIndex.js";
|
|
2
2
|
type Change = {
|
|
3
|
+
weight: number;
|
|
4
|
+
observation?: ReadObservationScope;
|
|
3
5
|
transform: ReadIndexTransform;
|
|
4
6
|
resolve: (state: ReadIndexState) => void;
|
|
5
7
|
reject: (error: unknown) => void;
|
|
@@ -13,6 +15,7 @@ export declare class BatchedReadIndex implements ReadIndexPersistence {
|
|
|
13
15
|
private queueLimit;
|
|
14
16
|
private queues;
|
|
15
17
|
private closed;
|
|
18
|
+
private sourceCounts;
|
|
16
19
|
constructor(storage: ReadIndexPersistence, windowMs?: number, batchSize?: number, queueLimit?: number);
|
|
17
20
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
18
21
|
loadVersioned(appId: string, profileId: string): Promise<{
|
|
@@ -24,7 +27,8 @@ export declare class BatchedReadIndex implements ReadIndexPersistence {
|
|
|
24
27
|
}>;
|
|
25
28
|
loadVersions(appId: string, profileId: string): Promise<{}>;
|
|
26
29
|
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
27
|
-
|
|
30
|
+
private weight;
|
|
31
|
+
update(appId: string, profileId: string, transform: Change["transform"], observation?: ReadObservationScope): Promise<ReadIndexState>;
|
|
28
32
|
private drain;
|
|
29
33
|
flush(appId: string, profileId: string): Promise<void>;
|
|
30
34
|
close(): Promise<void>;
|
|
@@ -8,6 +8,7 @@ export class BatchedReadIndex {
|
|
|
8
8
|
queueLimit;
|
|
9
9
|
queues = new Map();
|
|
10
10
|
closed = false;
|
|
11
|
+
sourceCounts = new WeakMap();
|
|
11
12
|
constructor(storage, windowMs = 16, batchSize = 128, queueLimit = 4096) {
|
|
12
13
|
this.storage = storage;
|
|
13
14
|
this.windowMs = windowMs;
|
|
@@ -42,7 +43,22 @@ export class BatchedReadIndex {
|
|
|
42
43
|
// barrier; any queued transform sees it inside its eventual transaction.
|
|
43
44
|
return this.storage.invalidateTeam(appId, profileId, teamId, version, knownThreadIds);
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
weight(observation) {
|
|
47
|
+
if (!observation)
|
|
48
|
+
return 1;
|
|
49
|
+
let counts = this.sourceCounts.get(observation.base);
|
|
50
|
+
if (!counts) {
|
|
51
|
+
counts = new Map();
|
|
52
|
+
for (const thread of observation.base.threads) {
|
|
53
|
+
counts.set(thread.id, (counts.get(thread.id) ?? 0) + 1);
|
|
54
|
+
if (thread.conversationId && thread.conversationId !== thread.id)
|
|
55
|
+
counts.set(thread.conversationId, (counts.get(thread.conversationId) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
this.sourceCounts.set(observation.base, counts);
|
|
58
|
+
}
|
|
59
|
+
return observation.targets.reduce((sum, target) => sum + (counts.get(target.threadId) ?? 1), 0);
|
|
60
|
+
}
|
|
61
|
+
update(appId, profileId, transform, observation) {
|
|
46
62
|
if (this.closed)
|
|
47
63
|
return Promise.reject(new Error("READ_STORAGE_CLOSED"));
|
|
48
64
|
const key = JSON.stringify([appId, profileId]);
|
|
@@ -53,7 +69,7 @@ export class BatchedReadIndex {
|
|
|
53
69
|
}
|
|
54
70
|
if (queue.pending.length >= this.queueLimit)
|
|
55
71
|
return Promise.reject(new Error("READ_STORAGE_BACKPRESSURE"));
|
|
56
|
-
const promise = new Promise((resolve, reject) => queue.pending.push({ transform, resolve, reject }));
|
|
72
|
+
const promise = new Promise((resolve, reject) => queue.pending.push({ transform, observation, weight: this.weight(observation), resolve, reject }));
|
|
57
73
|
if (!queue.running && queue.timer === undefined)
|
|
58
74
|
queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, this.windowMs);
|
|
59
75
|
return promise;
|
|
@@ -63,12 +79,22 @@ export class BatchedReadIndex {
|
|
|
63
79
|
return queue.running;
|
|
64
80
|
clearTimeout(queue.timer);
|
|
65
81
|
queue.timer = undefined;
|
|
66
|
-
const batch =
|
|
82
|
+
const batch = [];
|
|
83
|
+
let weight = 0;
|
|
84
|
+
while (queue.pending.length && batch.length < this.batchSize) {
|
|
85
|
+
const nextWeight = queue.pending[0].weight;
|
|
86
|
+
if (batch.length && weight + nextWeight > 128)
|
|
87
|
+
break;
|
|
88
|
+
weight += nextWeight;
|
|
89
|
+
batch.push(queue.pending.shift());
|
|
90
|
+
}
|
|
67
91
|
if (!batch.length)
|
|
68
92
|
return;
|
|
69
93
|
const run = (async () => {
|
|
70
94
|
try {
|
|
71
|
-
const
|
|
95
|
+
const first = batch[0].observation;
|
|
96
|
+
const observation = first && batch.every(change => change.observation?.base === first.base) ? { base: first.base, targets: batch.flatMap(change => change.observation.targets) } : undefined;
|
|
97
|
+
const next = await this.storage.update(queue.appId, queue.profileId, (state, versions) => batch.reduce((current, change) => change.transform(current, versions), state), observation);
|
|
72
98
|
batch.forEach(change => change.resolve(next));
|
|
73
99
|
}
|
|
74
100
|
catch (error) {
|
|
@@ -90,6 +90,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
90
90
|
private release;
|
|
91
91
|
private flushWrites;
|
|
92
92
|
private runWrites;
|
|
93
|
+
private assertNetworkAvailable;
|
|
93
94
|
synchronize(thread: string): Promise<void>;
|
|
94
95
|
/** Keep an explicitly requested history range in the shared subscribed view. */
|
|
95
96
|
expandWindow(thread: string, page: MessageSnapshot<T>): Promise<void>;
|
|
@@ -168,7 +168,8 @@ export class MessageClient {
|
|
|
168
168
|
const fallback = await this.fallback(thread, limit);
|
|
169
169
|
if (!fallback.messages.length)
|
|
170
170
|
throw error;
|
|
171
|
-
|
|
171
|
+
if (!isOfflineNetworkFailure(error))
|
|
172
|
+
this.options.onError?.(error, thread);
|
|
172
173
|
return fallback;
|
|
173
174
|
}
|
|
174
175
|
}
|
|
@@ -188,7 +189,8 @@ export class MessageClient {
|
|
|
188
189
|
const cached = await this.fallback(thread, limit);
|
|
189
190
|
if (!cached.messages.length)
|
|
190
191
|
throw error;
|
|
191
|
-
|
|
192
|
+
if (!isOfflineNetworkFailure(error))
|
|
193
|
+
this.options.onError?.(error, thread);
|
|
192
194
|
return cached;
|
|
193
195
|
}
|
|
194
196
|
const snapshot = await this.reload(thread);
|
|
@@ -433,6 +435,12 @@ export class MessageClient {
|
|
|
433
435
|
this.release(queue);
|
|
434
436
|
}
|
|
435
437
|
}
|
|
438
|
+
assertNetworkAvailable(thread) {
|
|
439
|
+
if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
|
|
440
|
+
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
441
|
+
if (isOffline())
|
|
442
|
+
throw new TypeError("MESSAGE_OFFLINE_UNAVAILABLE");
|
|
443
|
+
}
|
|
436
444
|
synchronize(thread) {
|
|
437
445
|
if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
|
|
438
446
|
return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
|
|
@@ -449,6 +457,7 @@ export class MessageClient {
|
|
|
449
457
|
}
|
|
450
458
|
let version = coverage?.resetVersion ?? 0;
|
|
451
459
|
let after = coverage?.complete ? coverage.checkpoint : undefined;
|
|
460
|
+
this.assertNetworkAvailable(thread);
|
|
452
461
|
let page = after && this.options.transport.changes
|
|
453
462
|
? await this.options.transport.changes(thread, after, this.abort.signal)
|
|
454
463
|
: await this.options.transport.load(thread, this.abort.signal);
|
|
@@ -457,6 +466,7 @@ export class MessageClient {
|
|
|
457
466
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
458
467
|
version = await this.storage.resetThread(thread, false, scopeVersion);
|
|
459
468
|
after = undefined;
|
|
469
|
+
this.assertNetworkAvailable(thread);
|
|
460
470
|
page = await this.options.transport.load(thread, this.abort.signal);
|
|
461
471
|
}
|
|
462
472
|
for (;;) {
|
|
@@ -470,6 +480,7 @@ export class MessageClient {
|
|
|
470
480
|
if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
|
|
471
481
|
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
472
482
|
after = page.checkpoint;
|
|
483
|
+
this.assertNetworkAvailable(thread);
|
|
473
484
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
474
485
|
}
|
|
475
486
|
})().catch(async (error) => {
|
|
@@ -530,7 +541,8 @@ export class MessageClient {
|
|
|
530
541
|
const fallback = await this.fallback(thread, limit, before);
|
|
531
542
|
if (!fallback.messages.length)
|
|
532
543
|
throw error;
|
|
533
|
-
|
|
544
|
+
if (!isOfflineNetworkFailure(error))
|
|
545
|
+
this.options.onError?.(error, thread);
|
|
534
546
|
return fallback;
|
|
535
547
|
}
|
|
536
548
|
}
|
|
@@ -546,6 +558,7 @@ export class MessageClient {
|
|
|
546
558
|
}
|
|
547
559
|
const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
|
|
548
560
|
const task = (async () => {
|
|
561
|
+
this.assertNetworkAvailable(thread);
|
|
549
562
|
const remote = await this.options.transport.older(thread, token, this.abort.signal);
|
|
550
563
|
if (this.closed || this.scopeDenied || this.revoked.has(thread))
|
|
551
564
|
throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
|
|
@@ -90,6 +90,14 @@ export declare function assertReadIndexVersions(expected: ReadIndexVersions, cur
|
|
|
90
90
|
/** Remove only the withdrawn team's original sources, logical conversations and
|
|
91
91
|
* queued observations. Other teams' pending operations must survive. */
|
|
92
92
|
export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
|
|
93
|
+
/** Internal hint for typed reading observations; arbitrary transforms omit it. */
|
|
94
|
+
export type ReadObservationScope = {
|
|
95
|
+
base: ReadIndexState;
|
|
96
|
+
targets: readonly {
|
|
97
|
+
threadId: string;
|
|
98
|
+
messageId: string;
|
|
99
|
+
}[];
|
|
100
|
+
};
|
|
93
101
|
export interface ReadIndexPersistence {
|
|
94
102
|
load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
|
|
95
103
|
loadVersioned?(appId: string, profileId: string): Promise<{
|
|
@@ -99,7 +107,7 @@ export interface ReadIndexPersistence {
|
|
|
99
107
|
loadVersions?(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
100
108
|
invalidateTeam?(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
101
109
|
/** Transform and persist atomically; UI publishes only the committed result. */
|
|
102
|
-
update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
|
|
110
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
|
|
103
111
|
}
|
|
104
112
|
/** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
|
|
105
113
|
export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
@@ -112,6 +120,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
112
120
|
private messageRows;
|
|
113
121
|
private positionRows;
|
|
114
122
|
private normalizedStates;
|
|
123
|
+
private observationMessages;
|
|
115
124
|
private versions;
|
|
116
125
|
private closed;
|
|
117
126
|
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
|
|
@@ -130,7 +139,8 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
130
139
|
}>;
|
|
131
140
|
loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
|
|
132
141
|
invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
|
|
133
|
-
|
|
142
|
+
private observe;
|
|
143
|
+
update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
|
|
134
144
|
close(): Promise<void>;
|
|
135
145
|
}
|
|
136
146
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -214,6 +214,7 @@ export class IndexedDbReadIndex {
|
|
|
214
214
|
messageRows;
|
|
215
215
|
positionRows;
|
|
216
216
|
normalizedStates = new WeakSet();
|
|
217
|
+
observationMessages = new WeakMap();
|
|
217
218
|
versions;
|
|
218
219
|
closed = false;
|
|
219
220
|
constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
|
|
@@ -227,6 +228,9 @@ export class IndexedDbReadIndex {
|
|
|
227
228
|
this.database.version(1).stores({ profiles: "" });
|
|
228
229
|
this.database.version(2).stores({ profiles: "", versions: "" });
|
|
229
230
|
this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
|
|
231
|
+
// Native indexes track scalar source identity even when an older writer
|
|
232
|
+
// changes rows. Reading these keys does not deserialize messageIds arrays.
|
|
233
|
+
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]" });
|
|
230
234
|
this.positionRows = this.database.table("readPositions");
|
|
231
235
|
this.threadRows = this.database.table("readThreads");
|
|
232
236
|
this.messageRows = this.database.table("readMessages");
|
|
@@ -435,8 +439,113 @@ export class IndexedDbReadIndex {
|
|
|
435
439
|
return next;
|
|
436
440
|
});
|
|
437
441
|
}
|
|
438
|
-
async
|
|
442
|
+
async observe(appId, profileId, transform, scope) {
|
|
443
|
+
if (!this.metadataOnly || scope.targets.length > 128)
|
|
444
|
+
return undefined;
|
|
445
|
+
const key = this.key(appId, profileId), owner = JSON.stringify(key), base = scope.base;
|
|
446
|
+
const header = await this.profiles.get(key);
|
|
447
|
+
if (header && (header.appId !== appId || header.profileId !== profileId || header.schemaVersion !== 1))
|
|
448
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
449
|
+
if (!header?.normalized || base.schemaVersion !== 1 || base.appId !== appId || base.profileId !== profileId || base.syncCursor !== header.syncCursor ||
|
|
450
|
+
header.threadOrder?.length !== base.threads.length || header.threadOrder.some((id, i) => id !== base.threads[i].id))
|
|
451
|
+
return undefined;
|
|
452
|
+
const sources = new Map();
|
|
453
|
+
for (const threadId of new Set(scope.targets.map(target => target.threadId))) {
|
|
454
|
+
const direct = await this.threadRows.where("[owner+id+value.kind+value.teamId]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
|
|
455
|
+
const assigned = await this.threadRows.where("[owner+value.conversationId+value.kind+value.teamId+id]").between([owner, threadId, Dexie.minKey], [owner, threadId, Dexie.maxKey], true, true).limit(129).keys();
|
|
456
|
+
if (direct.length + assigned.length > 128)
|
|
457
|
+
return undefined;
|
|
458
|
+
const identities = [...direct.map(value => ({ id: value[1], kind: value[2], teamId: value[3] })), ...assigned.map(value => ({ id: value[4], kind: value[2], teamId: value[3] }))];
|
|
459
|
+
if (!identities.length && base.threads.some(thread => thread.id === threadId || thread.conversationId === threadId))
|
|
460
|
+
return undefined;
|
|
461
|
+
for (const identity of identities) {
|
|
462
|
+
const cached = base.threads.find(thread => thread.id === identity.id);
|
|
463
|
+
if (!cached || cached.kind !== identity.kind || cached.teamId !== identity.teamId)
|
|
464
|
+
return undefined;
|
|
465
|
+
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();
|
|
466
|
+
if (mapping.length > 1 || cached.conversationId !== mapping[0]?.[2])
|
|
467
|
+
return undefined;
|
|
468
|
+
let source = sources.get(identity.id);
|
|
469
|
+
if (!source) {
|
|
470
|
+
source = { cached, ids: new Set() };
|
|
471
|
+
sources.set(identity.id, source);
|
|
472
|
+
}
|
|
473
|
+
for (const target of scope.targets)
|
|
474
|
+
if (target.threadId === threadId)
|
|
475
|
+
source.ids.add(target.messageId);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if ([...sources.values()].reduce((sum, source) => sum + source.ids.size, 0) > 128)
|
|
479
|
+
return undefined;
|
|
480
|
+
const sourceIds = [...sources.keys()];
|
|
481
|
+
// Cursors are the mutable cross-document view. Refresh their small rows,
|
|
482
|
+
// while message metadata remains targeted; otherwise a later batch based
|
|
483
|
+
// on an older snapshot could regress a different conversation's progress.
|
|
484
|
+
const allPositions = await this.positionRows.where("owner").equals(owner).toArray();
|
|
485
|
+
const positionById = new Map(allPositions.map(position => [position.id, position]));
|
|
486
|
+
if (base.threads.some(thread => !positionById.has(thread.id)))
|
|
487
|
+
return undefined;
|
|
488
|
+
const positions = sourceIds.map(id => positionById.get(id));
|
|
489
|
+
const partialThreads = [];
|
|
490
|
+
for (let i = 0; i < sourceIds.length; i++) {
|
|
491
|
+
const id = sourceIds[i], source = sources.get(id), position = positions[i];
|
|
492
|
+
if (!position)
|
|
493
|
+
return undefined;
|
|
494
|
+
let cached = this.observationMessages.get(source.cached.messages);
|
|
495
|
+
if (!cached) {
|
|
496
|
+
cached = new Map(source.cached.messages.map(message => [message.id, message]));
|
|
497
|
+
this.observationMessages.set(source.cached.messages, cached);
|
|
498
|
+
}
|
|
499
|
+
const ids = [...source.ids], rows = await this.messageRows.bulkGet(ids.map(messageId => [owner, id, messageId]));
|
|
500
|
+
const messages = [];
|
|
501
|
+
for (let j = 0; j < ids.length; j++) {
|
|
502
|
+
const previous = cached.get(ids[j]), message = rows[j]?.value;
|
|
503
|
+
if (message && (message.payload !== null || typeof message.readingControl !== "boolean"))
|
|
504
|
+
return undefined;
|
|
505
|
+
if (JSON.stringify(previous) !== JSON.stringify(message))
|
|
506
|
+
return undefined;
|
|
507
|
+
if (message)
|
|
508
|
+
messages.push(previous);
|
|
509
|
+
}
|
|
510
|
+
partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds });
|
|
511
|
+
}
|
|
512
|
+
const partial = { schemaVersion: 1, appId, profileId, syncCursor: header.syncCursor, pending: header.pending, observedReadIntents: header.observedReadIntents, threads: partialThreads };
|
|
513
|
+
const result = transform(partial, (await this.versions.get(key))?.teams ?? {});
|
|
514
|
+
if (result.appId !== appId || result.profileId !== profileId || result.schemaVersion !== 1)
|
|
515
|
+
throw new Error("READ_IDENTITY_MISMATCH");
|
|
516
|
+
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))
|
|
517
|
+
return undefined;
|
|
518
|
+
const writes = [];
|
|
519
|
+
for (let i = 0; i < result.threads.length; i++) {
|
|
520
|
+
const thread = result.threads[i], before = positions[i];
|
|
521
|
+
if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
|
|
522
|
+
writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
|
|
523
|
+
}
|
|
524
|
+
if (writes.length)
|
|
525
|
+
await this.positionRows.bulkPut(writes);
|
|
526
|
+
if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
|
|
527
|
+
await this.profiles.put({ ...header, pending: result.pending, observedReadIntents: result.observedReadIntents }, key);
|
|
528
|
+
const byId = new Map(allPositions.map(position => [position.id, position]));
|
|
529
|
+
result.threads.forEach(thread => byId.set(thread.id, thread));
|
|
530
|
+
let changed = false;
|
|
531
|
+
const threads = base.threads.map(thread => {
|
|
532
|
+
const position = byId.get(thread.id);
|
|
533
|
+
if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
|
|
534
|
+
return thread;
|
|
535
|
+
changed = true;
|
|
536
|
+
return { ...thread, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds };
|
|
537
|
+
});
|
|
538
|
+
if (!changed && JSON.stringify(base.pending) === JSON.stringify(result.pending) && JSON.stringify(base.observedReadIntents) === JSON.stringify(result.observedReadIntents))
|
|
539
|
+
return base;
|
|
540
|
+
return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
|
|
541
|
+
}
|
|
542
|
+
async update(appId, profileId, transform, observation) {
|
|
439
543
|
return this.transaction("rw", async () => {
|
|
544
|
+
if (observation) {
|
|
545
|
+
const next = await this.observe(appId, profileId, transform, observation);
|
|
546
|
+
if (next)
|
|
547
|
+
return next;
|
|
548
|
+
}
|
|
440
549
|
const previous = await this.loadCurrent(appId, profileId);
|
|
441
550
|
const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
|
|
442
551
|
const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
|
|
@@ -39,7 +39,7 @@ export class ReadingClient {
|
|
|
39
39
|
getSnapshot = () => this.state;
|
|
40
40
|
subscribe = (listener) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
|
|
41
41
|
publish(state) {
|
|
42
|
-
if (this.closed || JSON.stringify(state) === JSON.stringify(this.state))
|
|
42
|
+
if (this.closed || state === this.state || JSON.stringify(state) === JSON.stringify(this.state))
|
|
43
43
|
return;
|
|
44
44
|
this.state = state;
|
|
45
45
|
this.listeners.forEach(listener => listener());
|
|
@@ -102,7 +102,7 @@ export class ReadingClient {
|
|
|
102
102
|
// methods below only derive changes for their configured team.
|
|
103
103
|
return this.updateScoped(transform);
|
|
104
104
|
}
|
|
105
|
-
updateScoped(transform, teamId) {
|
|
105
|
+
updateScoped(transform, teamId, observation) {
|
|
106
106
|
if (this.closed)
|
|
107
107
|
return Promise.reject(new Error("READING_CLIENT_CLOSED"));
|
|
108
108
|
// One shared metadata read per local authority generation, rather than one
|
|
@@ -114,7 +114,7 @@ export class ReadingClient {
|
|
|
114
114
|
assertReadIndexVersions(expected, currentVersions, teamId);
|
|
115
115
|
const next = transform(state);
|
|
116
116
|
return this.options.metadataOnly ? readingMetadata(next) : next;
|
|
117
|
-
});
|
|
117
|
+
}, observation);
|
|
118
118
|
if (!this.closed) {
|
|
119
119
|
this.publish(next);
|
|
120
120
|
if (next.pending.length || next.observedReadIntents?.length)
|
|
@@ -162,7 +162,16 @@ export class ReadingClient {
|
|
|
162
162
|
recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
|
|
163
163
|
record(threadId, ids, readPolicy) {
|
|
164
164
|
const messageIds = [...new Set(ids)];
|
|
165
|
-
|
|
165
|
+
if (!messageIds.length && this.state) {
|
|
166
|
+
try {
|
|
167
|
+
this.assertThreadTeam(this.state, threadId);
|
|
168
|
+
return Promise.resolve(this.state);
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
return Promise.reject(error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
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, this.state && messageIds.length ? { base: this.state, targets: messageIds.map(messageId => ({ threadId, messageId })) } : undefined);
|
|
166
175
|
}
|
|
167
176
|
withdraw() { this.stop(); }
|
|
168
177
|
stop() {
|
package/package.json
CHANGED