@pmate/chat-sdk 0.12.0-next.44 → 0.12.0-next.46
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/durableSender.d.ts +1 -0
- package/lib/runtime/durableSender.js +46 -2
- 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) {
|
|
@@ -39,8 +39,11 @@ export class DurableChatSender {
|
|
|
39
39
|
this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
40
40
|
this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
41
41
|
this.db.version(2).stores({ scopeEpochs: "scope" });
|
|
42
|
+
this.db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
42
43
|
this.outbox = this.db.table("outbox");
|
|
43
44
|
this.epochs = this.db.table("scopeEpochs");
|
|
45
|
+
this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
|
|
46
|
+
this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
|
|
44
47
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
45
48
|
this.ready = this.restore();
|
|
46
49
|
void this.ready.catch(() => { });
|
|
@@ -61,6 +64,7 @@ export class DurableChatSender {
|
|
|
61
64
|
const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
62
65
|
db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
63
66
|
db.version(2).stores({ scopeEpochs: "scope" });
|
|
67
|
+
db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
64
68
|
try {
|
|
65
69
|
return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
|
|
66
70
|
if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
|
|
@@ -90,7 +94,9 @@ export class DurableChatSender {
|
|
|
90
94
|
await this.epochs.put({ scope: this.scope, version });
|
|
91
95
|
await this.deleteRows();
|
|
92
96
|
}
|
|
93
|
-
|
|
97
|
+
const total = await this.outbox.where("scope").equals(this.scope).count();
|
|
98
|
+
const indexed = await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, Dexie.minKey], [this.scope, 1, Dexie.maxKey], true, true).count();
|
|
99
|
+
return { version, advanced: version > current, legacy: total !== indexed };
|
|
94
100
|
});
|
|
95
101
|
this.version = loaded.version;
|
|
96
102
|
if (loaded.advanced)
|
|
@@ -100,7 +106,45 @@ export class DurableChatSender {
|
|
|
100
106
|
const current = await this.readAccess();
|
|
101
107
|
if (current && (!current.allowed || current.version !== this.version))
|
|
102
108
|
return;
|
|
103
|
-
|
|
109
|
+
if (access?.allowed === false)
|
|
110
|
+
return;
|
|
111
|
+
let after;
|
|
112
|
+
const restored = new Map();
|
|
113
|
+
while (!this.closed && !this.withdrawn) {
|
|
114
|
+
const batch = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
115
|
+
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
116
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
117
|
+
const keys = await (loaded.legacy
|
|
118
|
+
? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
|
|
119
|
+
: this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(128).primaryKeys();
|
|
120
|
+
if (!keys.length)
|
|
121
|
+
return { last: undefined, records: [] };
|
|
122
|
+
const last = keys.at(-1)[1];
|
|
123
|
+
// Key-only metadata covers confirmed history without reading wire bodies.
|
|
124
|
+
// Missing metadata identifies rows written by an older SDK after upgrade.
|
|
125
|
+
const confirmed = loaded.legacy ? await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, keys[0][1]], [this.scope, 0, last], true, true).primaryKeys() : [];
|
|
126
|
+
const confirmedHashes = new Set(confirmed.map(key => key[1]));
|
|
127
|
+
const needed = keys.filter(key => !confirmedHashes.has(key[1]));
|
|
128
|
+
const rows = (await this.outbox.bulkGet(needed)).filter((row) => Boolean(row));
|
|
129
|
+
const repairs = rows.filter(row => row.pendingState !== (row.confirmed ? 0 : 1));
|
|
130
|
+
if (repairs.length)
|
|
131
|
+
await this.outbox.bulkPut(repairs.map(row => ({ ...row, pendingState: row.confirmed ? 0 : 1 })));
|
|
132
|
+
return { last, records: rows.filter(row => !row.confirmed && (row.accessVersion ?? 0) === this.version) };
|
|
133
|
+
});
|
|
134
|
+
for (const row of batch.records)
|
|
135
|
+
restored.set(row.hash, row);
|
|
136
|
+
if (batch.last === undefined)
|
|
137
|
+
break;
|
|
138
|
+
after = batch.last;
|
|
139
|
+
// Leave the database transaction before yielding to input/rendering work.
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
141
|
+
}
|
|
142
|
+
if (this.closed || this.withdrawn)
|
|
143
|
+
return;
|
|
144
|
+
const latest = await this.readAccess();
|
|
145
|
+
if (latest && (!latest.allowed || latest.version !== this.version))
|
|
146
|
+
return;
|
|
147
|
+
this.values = restored;
|
|
104
148
|
this.emit();
|
|
105
149
|
}
|
|
106
150
|
async checkAccess() {
|
|
@@ -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