@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -1
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/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 +78 -8
- package/lib/runtime/chatSession.js +504 -39
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +4 -5
- package/lib/runtime/createChat.js +14 -17
- package/lib/runtime/durableSender.d.ts +30 -4
- package/lib/runtime/durableSender.js +174 -34
- package/lib/runtime/index.d.ts +4 -0
- package/lib/runtime/index.js +4 -0
- package/lib/runtime/messageClient.d.ts +33 -6
- package/lib/runtime/messageClient.js +443 -71
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +42 -0
- package/lib/runtime/messageLaunch.js +127 -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 +47 -2
- package/lib/runtime/messageStore.js +233 -10
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +44 -6
- package/lib/runtime/readIndex.js +238 -11
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +21 -8
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +121 -18
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/package.json +6 -1
|
@@ -1,68 +1,291 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { quotedMessageIds, redactMessageQuotes } from "./messageQuotes.js";
|
|
3
|
+
import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
|
|
2
4
|
export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
|
|
3
5
|
/** Normalized browser cache. Data and coverage advance together in each commit.
|
|
4
6
|
* Merge runs inside the write transaction against the latest cross-tab state. */
|
|
5
7
|
export class IndexedDbMessageStore {
|
|
8
|
+
scope;
|
|
6
9
|
merge;
|
|
7
10
|
db;
|
|
8
11
|
rows;
|
|
12
|
+
legacy;
|
|
9
13
|
coverage;
|
|
14
|
+
accessRows;
|
|
15
|
+
controls;
|
|
10
16
|
key;
|
|
11
17
|
constructor(scope, merge, options = {}) {
|
|
18
|
+
this.scope = scope;
|
|
12
19
|
this.merge = merge;
|
|
13
20
|
this.key = messageScopeKey(scope);
|
|
14
21
|
this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
15
22
|
this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
|
|
23
|
+
this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
|
|
24
|
+
this.db.version(3).stores({ scopeAccess: "scope" });
|
|
25
|
+
this.db.version(4).stores({ messageControls: "[scope+thread+target+kind+actor+emoji],[scope+thread+target],[scope+thread],scope" });
|
|
26
|
+
this.controls = this.db.table("messageControls");
|
|
27
|
+
this.accessRows = this.db.table("scopeAccess");
|
|
28
|
+
this.legacy = this.db.table("legacyMessages");
|
|
16
29
|
this.rows = this.db.table("messages");
|
|
17
30
|
this.coverage = this.db.table("coverage");
|
|
18
31
|
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
19
32
|
}
|
|
33
|
+
get namespace() { return this.db.name; }
|
|
20
34
|
async read(thread, limit = 100, before) {
|
|
21
35
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
22
36
|
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
23
|
-
|
|
37
|
+
const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
38
|
+
const access = await this.scopeAccess();
|
|
39
|
+
if (access.revoked)
|
|
40
|
+
return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
|
|
24
41
|
const range = this.rows.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before);
|
|
25
42
|
const records = await range.reverse().limit(limit + 1).toArray();
|
|
26
|
-
return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
|
|
43
|
+
return this.controlPage({ messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) });
|
|
27
44
|
});
|
|
45
|
+
return this.finishPage(thread, page);
|
|
28
46
|
}
|
|
29
|
-
|
|
47
|
+
/** Bounded identifier reads for SDK-owned view handoffs; no transport fallback. */
|
|
48
|
+
async readSelected(thread, ids) {
|
|
49
|
+
if (ids.length > 100 || ids.some(id => !id || id.length > 256))
|
|
50
|
+
throw new Error("INVALID_MESSAGE_SELECTION");
|
|
51
|
+
const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
|
|
52
|
+
const access = await this.scopeAccess();
|
|
53
|
+
const coverage = this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
54
|
+
if (access.revoked || coverage?.revoked || (coverage?.scopeVersion ?? 0) !== access.version)
|
|
55
|
+
return { messages: [], hasEarlier: false, coverage: { ...coverage, complete: false, revoked: true, scopeVersion: access.version } };
|
|
56
|
+
const records = await this.rows.bulkGet([...new Set(ids)].map(id => [this.key, thread, id]));
|
|
57
|
+
return this.controlPage({ messages: records.flatMap(row => row ? [row.value] : []), hasEarlier: false, coverage });
|
|
58
|
+
});
|
|
59
|
+
return this.finishPage(thread, page);
|
|
60
|
+
}
|
|
61
|
+
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
62
|
+
async importLegacy(thread, messages) {
|
|
63
|
+
if (messages.length > 128)
|
|
64
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
65
|
+
let bytes = 0;
|
|
66
|
+
for (const message of messages) {
|
|
67
|
+
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
68
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
69
|
+
bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
|
|
70
|
+
if (bytes > 1_048_576)
|
|
71
|
+
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
72
|
+
}
|
|
73
|
+
return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
74
|
+
const access = await this.scopeAccess();
|
|
75
|
+
const coverage = await this.coverage.get([this.key, thread]);
|
|
76
|
+
if (access.revoked || coverage?.revoked)
|
|
77
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
78
|
+
if (access.version > 0)
|
|
79
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
80
|
+
const proofs = messages.map(message => messageControls(message, this.scope, this.key));
|
|
81
|
+
const controlsOnly = messages.length > 0 && proofs.every((events, index) => events.length > 0 && !messages[index].recalled && !messages[index].legacyDisplay?.recalled);
|
|
82
|
+
if (controlsOnly) {
|
|
83
|
+
await this.rememberControls(proofs.flat());
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
if ((coverage?.resetVersion ?? 0) > 0)
|
|
87
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
88
|
+
const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
|
|
89
|
+
const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
|
|
90
|
+
const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
|
|
91
|
+
const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
|
|
92
|
+
for (const message of messages) {
|
|
93
|
+
if (known.has(message.id))
|
|
94
|
+
continue;
|
|
95
|
+
const old = latest.get(message.id), value = this.merge(old, message);
|
|
96
|
+
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
97
|
+
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
98
|
+
latest.set(message.id, value);
|
|
99
|
+
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
100
|
+
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
101
|
+
}
|
|
102
|
+
await this.rememberControls([...changed.values()].flatMap(row => messageControls(row.value, this.scope, this.key)));
|
|
103
|
+
if (changed.size)
|
|
104
|
+
await this.legacy.bulkPut([...changed.values()]);
|
|
105
|
+
return changed.size;
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/** Bounded fallback only; callers must report an incomplete window. */
|
|
109
|
+
async readFallback(thread, limit = 100, before) {
|
|
110
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
111
|
+
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
112
|
+
const page = await this.db.transaction("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
113
|
+
const access = await this.scopeAccess();
|
|
114
|
+
const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
115
|
+
if (coverage?.revoked)
|
|
116
|
+
return { messages: [], hasEarlier: false, coverage };
|
|
117
|
+
const read = (table) => table.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
|
|
118
|
+
const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
|
|
119
|
+
const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
120
|
+
return this.controlPage({ messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage });
|
|
121
|
+
});
|
|
122
|
+
return this.finishPage(thread, page);
|
|
123
|
+
}
|
|
124
|
+
async commit(thread, messages, coverage, guard) {
|
|
30
125
|
for (const message of messages)
|
|
31
126
|
if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
|
|
32
127
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
33
128
|
if (messages.length > 1000)
|
|
34
129
|
throw new Error("MESSAGE_BATCH_TOO_LARGE");
|
|
35
|
-
return this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
130
|
+
return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
131
|
+
const access = await this.scopeAccess();
|
|
132
|
+
if ((guard?.scopeVersion ?? 0) !== access.version)
|
|
133
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
134
|
+
if (access.revoked) {
|
|
135
|
+
if (!guard?.authoritative)
|
|
136
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
137
|
+
await this.clearScopeRows();
|
|
138
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
139
|
+
}
|
|
140
|
+
const current = await this.coverage.get([this.key, thread]);
|
|
141
|
+
if (guard && (current?.resetVersion ?? 0) !== guard.version)
|
|
142
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
143
|
+
if (guard && current?.revoked && !guard.authoritative)
|
|
144
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
36
145
|
const ids = [...new Set(messages.map(message => message.id))];
|
|
37
|
-
const
|
|
38
|
-
const
|
|
146
|
+
const keys = ids.map(id => [this.key, thread, id]);
|
|
147
|
+
const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
|
|
148
|
+
const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
|
|
149
|
+
const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
|
|
39
150
|
const changed = new Map();
|
|
40
151
|
for (const message of messages) {
|
|
41
152
|
const old = latest.get(message.id), value = this.merge(old, message);
|
|
42
153
|
if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
|
|
43
154
|
throw new Error("MESSAGE_SCOPE_MISMATCH");
|
|
44
155
|
latest.set(message.id, value);
|
|
45
|
-
if (JSON.stringify(old) !== JSON.stringify(value))
|
|
156
|
+
if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
|
|
46
157
|
changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
|
|
47
158
|
}
|
|
159
|
+
await this.rememberControls(messages.flatMap(message => messageControls(message, this.scope, this.key)));
|
|
48
160
|
if (changed.size)
|
|
49
161
|
await this.rows.bulkPut([...changed.values()]);
|
|
162
|
+
const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
|
|
163
|
+
if (replaced.length)
|
|
164
|
+
await this.legacy.bulkDelete(replaced);
|
|
50
165
|
if (coverage) {
|
|
51
|
-
const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
|
|
166
|
+
const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
|
|
52
167
|
if (JSON.stringify(old) !== JSON.stringify(next))
|
|
53
168
|
await this.coverage.put(next);
|
|
54
169
|
}
|
|
55
170
|
return changed.size;
|
|
56
171
|
});
|
|
57
172
|
}
|
|
173
|
+
async rememberControls(events) {
|
|
174
|
+
const latest = new Map();
|
|
175
|
+
for (const event of events) {
|
|
176
|
+
const key = JSON.stringify(controlKey(event));
|
|
177
|
+
latest.set(key, newerControl(latest.get(key), event));
|
|
178
|
+
}
|
|
179
|
+
const values = [...latest.values()];
|
|
180
|
+
for (let offset = 0; offset < values.length; offset += 128) {
|
|
181
|
+
const batch = values.slice(offset, offset + 128), previous = await this.controls.bulkGet(batch.map(controlKey));
|
|
182
|
+
const changed = batch.filter((event, index) => newerControl(previous[index], event) !== previous[index]);
|
|
183
|
+
if (changed.length)
|
|
184
|
+
await this.controls.bulkPut(changed);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async controlPage(page) {
|
|
188
|
+
const events = page.messages.flatMap(message => messageControls(message, this.scope, this.key));
|
|
189
|
+
const old = events.length ? await this.controls.bulkGet(events.map(controlKey)) : [];
|
|
190
|
+
const pendingControls = events.filter((event, index) => newerControl(old[index], event) !== old[index]);
|
|
191
|
+
const ids = new Set(page.messages.map(message => message.id));
|
|
192
|
+
const references = [...new Set(page.messages.flatMap(quotedMessageIds))].filter(id => !ids.has(id));
|
|
193
|
+
const thread = page.messages[0]?.threadId;
|
|
194
|
+
const targets = thread && references.length ? (await this.rows.bulkGet(references.map(id => [this.key, thread, id]))).flatMap(row => row ? [row.value] : []) : [];
|
|
195
|
+
const selected = [...page.messages, ...targets];
|
|
196
|
+
const facts = await this.controls.where("[scope+thread+target]").anyOf(selected.map(message => [this.key, message.threadId, message.id])).toArray();
|
|
197
|
+
const latest = new Map();
|
|
198
|
+
for (const event of [...facts, ...events]) {
|
|
199
|
+
const key = JSON.stringify(controlKey(event));
|
|
200
|
+
latest.set(key, newerControl(latest.get(key), event));
|
|
201
|
+
}
|
|
202
|
+
const controlled = applyMessageControls(selected, [...latest.values()]);
|
|
203
|
+
return { ...page, messages: redactMessageQuotes(controlled.slice(0, page.messages.length), controlled), pendingControls };
|
|
204
|
+
}
|
|
205
|
+
async finishPage(thread, page) {
|
|
206
|
+
const { pendingControls, ...result } = page;
|
|
207
|
+
// v3 caches migrate only source proofs from already-read pages, with no body
|
|
208
|
+
// rewrite. Steady reads remain a single read-only IndexedDB transaction.
|
|
209
|
+
if (pendingControls?.length)
|
|
210
|
+
await this.db.transaction("rw", this.controls, this.coverage, this.accessRows, async () => {
|
|
211
|
+
const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
|
|
212
|
+
if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
|
|
213
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
214
|
+
if ((current?.resetVersion ?? 0) !== (page.coverage?.resetVersion ?? 0))
|
|
215
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
216
|
+
await this.rememberControls(pendingControls);
|
|
217
|
+
});
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
async windows() {
|
|
221
|
+
return this.db.transaction("r", this.coverage, this.accessRows, async () => {
|
|
222
|
+
if ((await this.scopeAccess()).revoked)
|
|
223
|
+
return [];
|
|
224
|
+
return this.coverage.where("scope").equals(this.key).toArray();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async scopeAccess() {
|
|
228
|
+
const access = (await this.accessRows.get(this.key)) ?? { scope: this.key, version: 0, revoked: false };
|
|
229
|
+
if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
|
|
230
|
+
throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
|
|
231
|
+
return access;
|
|
232
|
+
}
|
|
233
|
+
scopedCoverage(coverage, access) {
|
|
234
|
+
return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
|
|
235
|
+
}
|
|
236
|
+
async clearScopeRows() {
|
|
237
|
+
await this.controls.where("scope").equals(this.key).delete();
|
|
238
|
+
await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
239
|
+
await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
|
|
240
|
+
await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
|
|
241
|
+
}
|
|
242
|
+
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
243
|
+
async revokeScope(expectedVersion) {
|
|
244
|
+
const access = await this.db.transaction("rw", this.accessRows, async () => {
|
|
245
|
+
const old = await this.scopeAccess();
|
|
246
|
+
if (expectedVersion !== undefined && old.version !== expectedVersion)
|
|
247
|
+
return old;
|
|
248
|
+
const next = { scope: this.key, version: old.version + 1, revoked: true };
|
|
249
|
+
await this.accessRows.put(next);
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
253
|
+
const current = await this.scopeAccess();
|
|
254
|
+
if (current.version === access.version && current.revoked)
|
|
255
|
+
await this.clearScopeRows();
|
|
256
|
+
});
|
|
257
|
+
return access.version;
|
|
258
|
+
}
|
|
259
|
+
/** A durable epoch prevents another tab's pre-reset request from restoring rows. */
|
|
260
|
+
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
261
|
+
return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
262
|
+
const access = await this.scopeAccess();
|
|
263
|
+
if (access.version !== scopeVersion)
|
|
264
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
265
|
+
if (access.revoked)
|
|
266
|
+
throw new Error("MESSAGE_ACCESS_REVOKED");
|
|
267
|
+
const old = await this.coverage.get([this.key, thread]);
|
|
268
|
+
const resetVersion = (old?.resetVersion ?? 0) + 1;
|
|
269
|
+
if (revoked)
|
|
270
|
+
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
271
|
+
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
272
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
273
|
+
await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
|
|
274
|
+
return resetVersion;
|
|
275
|
+
});
|
|
276
|
+
}
|
|
58
277
|
async removeThread(thread) {
|
|
59
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
278
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
279
|
+
await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
60
280
|
await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
281
|
+
await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
|
|
61
282
|
await this.coverage.delete([this.key, thread]);
|
|
62
283
|
});
|
|
63
284
|
}
|
|
64
285
|
async clear() {
|
|
65
|
-
await this.db.transaction("rw", this.rows, this.coverage, async () => {
|
|
286
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
287
|
+
await this.controls.where("scope").equals(this.key).delete();
|
|
288
|
+
await this.legacy.where("scope").equals(this.key).delete();
|
|
66
289
|
await this.rows.where("scope").equals(this.key).delete();
|
|
67
290
|
await this.coverage.where("scope").equals(this.key).delete();
|
|
68
291
|
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { StoredMessage } from "./messageStore.js";
|
|
2
|
+
import type { OutgoingMessage } from "./durableSender.js";
|
|
3
|
+
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
4
|
+
import type { MessageReadingState, ReadingCandidate } from "./conversationReading.js";
|
|
5
|
+
export type TimelineReactionEvent = Reaction & {
|
|
6
|
+
threadId: string;
|
|
7
|
+
};
|
|
8
|
+
export type TimelineReaction = {
|
|
9
|
+
emojiId: string;
|
|
10
|
+
userIds: string[];
|
|
11
|
+
mine: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type TimelineItem<T> = {
|
|
14
|
+
id: string;
|
|
15
|
+
threadId: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
from?: string;
|
|
18
|
+
mine: boolean;
|
|
19
|
+
status: "sending" | "sent" | "failed" | "read";
|
|
20
|
+
recalled: boolean;
|
|
21
|
+
unread?: boolean;
|
|
22
|
+
attentionEligible?: boolean;
|
|
23
|
+
message: T | undefined;
|
|
24
|
+
reading?: MessageReadingState;
|
|
25
|
+
reactions: readonly TimelineReaction[];
|
|
26
|
+
reactionEvents: readonly TimelineReactionEvent[];
|
|
27
|
+
};
|
|
28
|
+
type Reaction = {
|
|
29
|
+
id: string;
|
|
30
|
+
messageId: string;
|
|
31
|
+
from: string;
|
|
32
|
+
emojiId: string;
|
|
33
|
+
active: boolean;
|
|
34
|
+
revision: number;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
};
|
|
37
|
+
/** Derives one display stream without copying bodies or creating another cache.
|
|
38
|
+
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
39
|
+
export declare function projectMessageTimeline<T extends StoredMessage>(input: {
|
|
40
|
+
appId: string;
|
|
41
|
+
profileId: string;
|
|
42
|
+
threadId: string;
|
|
43
|
+
messages: readonly T[];
|
|
44
|
+
outgoing: readonly OutgoingMessage[];
|
|
45
|
+
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
46
|
+
reading: Readonly<Record<string, MessageReadingState>>;
|
|
47
|
+
candidates?: ReadonlyMap<string, ReadingCandidate>;
|
|
48
|
+
previous?: readonly TimelineItem<T>[];
|
|
49
|
+
}): readonly TimelineItem<T>[];
|
|
50
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
|
+
function content(message, appId) {
|
|
3
|
+
const body = message.body;
|
|
4
|
+
return body?.kind === "message" && body.content?.appId === appId ? body : undefined;
|
|
5
|
+
}
|
|
6
|
+
function nativeRecall(message, appId) {
|
|
7
|
+
const body = message.body;
|
|
8
|
+
return message.appId === appId && body?.kind === "recall" && typeof body.targetMessageId === "string" && body.targetMessageId.length > 0 && body.targetMessageId.length <= 256 ? body.targetMessageId : undefined;
|
|
9
|
+
}
|
|
10
|
+
function isControl(message, appId) { return isReadingControl(message.body, appId) || nativeRecall(message, appId) !== undefined; }
|
|
11
|
+
function validReaction(value) {
|
|
12
|
+
if (!value || typeof value !== "object")
|
|
13
|
+
return false;
|
|
14
|
+
const event = value;
|
|
15
|
+
return [event.id, event.messageId, event.from, event.emojiId].every(field => typeof field === "string" && field.length > 0 && field.length <= 256) && typeof event.active === "boolean" && Number.isSafeInteger(event.revision) && event.revision >= 0 && Number.isFinite(event.timestamp);
|
|
16
|
+
}
|
|
17
|
+
/** Derives one display stream without copying bodies or creating another cache.
|
|
18
|
+
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
19
|
+
export function projectMessageTimeline(input) {
|
|
20
|
+
const scoped = (message) => {
|
|
21
|
+
const value = message, body = value.body;
|
|
22
|
+
const app = body?.content?.appId ?? body?.payload?.appId;
|
|
23
|
+
return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
|
|
24
|
+
};
|
|
25
|
+
const tombstones = new Set(input.messages.filter(message => scoped(message) && (message.recalled || message.legacyDisplay?.recalled)).map(message => message.id));
|
|
26
|
+
const records = new Map();
|
|
27
|
+
for (const pending of input.outgoing) {
|
|
28
|
+
if (pending.threadId !== input.threadId || pending.confirmed)
|
|
29
|
+
continue;
|
|
30
|
+
const message = input.decode(pending.message);
|
|
31
|
+
if (message && scoped(message))
|
|
32
|
+
records.set(message.id, { message, status: pending.status });
|
|
33
|
+
}
|
|
34
|
+
for (const message of input.messages)
|
|
35
|
+
if (scoped(message)) {
|
|
36
|
+
const legacy = message.legacyDisplay;
|
|
37
|
+
if (legacy && records.has(message.id))
|
|
38
|
+
continue;
|
|
39
|
+
const legacyStatus = legacy?.status;
|
|
40
|
+
const status = legacyStatus === "failed" || legacyStatus === "sending" || legacyStatus === "read" ? legacyStatus : "sent";
|
|
41
|
+
records.set(message.id, { message, status });
|
|
42
|
+
}
|
|
43
|
+
const recalled = new Map(), reactions = new Map();
|
|
44
|
+
const addReaction = (event) => {
|
|
45
|
+
const key = JSON.stringify([event.messageId, event.from, event.emojiId]), old = reactions.get(key);
|
|
46
|
+
if (!old || event.revision > old.revision || event.revision === old.revision && (event.timestamp > old.timestamp || event.timestamp === old.timestamp && event.id > old.id))
|
|
47
|
+
reactions.set(key, event);
|
|
48
|
+
};
|
|
49
|
+
for (const { message, status } of records.values()) {
|
|
50
|
+
if (status === "failed" && isControl(message, input.appId))
|
|
51
|
+
continue;
|
|
52
|
+
const envelope = message, body = content(envelope, input.appId), target = nativeRecall(envelope, input.appId) ?? body?.content?.recall?.messageId;
|
|
53
|
+
if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
|
|
54
|
+
if (!recalled.has(target))
|
|
55
|
+
recalled.set(target, new Set());
|
|
56
|
+
recalled.get(target).add(envelope.from);
|
|
57
|
+
}
|
|
58
|
+
const reaction = body?.content?.reaction;
|
|
59
|
+
if (body?.contentKind === "text" && reaction?.version === 1 && typeof reaction.messageId === "string" && typeof reaction.emojiId === "string" && typeof reaction.active === "boolean" && typeof reaction.revision === "number" && reaction.revision >= 1 && envelope.from) {
|
|
60
|
+
const event = { id: message.id, messageId: reaction.messageId, from: envelope.from, emojiId: reaction.emojiId, active: reaction.active, revision: reaction.revision, timestamp: message.timestamp };
|
|
61
|
+
if (validReaction(event))
|
|
62
|
+
addReaction(event);
|
|
63
|
+
}
|
|
64
|
+
const events = envelope.reactionEvents ?? envelope.legacyDisplay?.reactionEvents;
|
|
65
|
+
if (Array.isArray(events))
|
|
66
|
+
for (const event of events)
|
|
67
|
+
if (validReaction(event) && event.messageId === message.id && (event.threadId === undefined || event.threadId === input.threadId))
|
|
68
|
+
addReaction(event);
|
|
69
|
+
// Old display-only imports may have aggregates but no event IDs. Treat them
|
|
70
|
+
// as revision-zero baselines; any durable control supersedes them.
|
|
71
|
+
const baseline = envelope.legacyDisplay?.reactions;
|
|
72
|
+
if (Array.isArray(baseline))
|
|
73
|
+
for (const value of baseline) {
|
|
74
|
+
if (typeof value?.emojiId !== "string" || !Array.isArray(value.userIds))
|
|
75
|
+
continue;
|
|
76
|
+
for (const from of value.userIds) {
|
|
77
|
+
const event = { id: `legacy:${message.id}`, messageId: message.id, from, emojiId: value.emojiId, active: true, revision: 0, timestamp: 0 };
|
|
78
|
+
if (validReaction(event))
|
|
79
|
+
addReaction(event);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const groupedEvents = new Map();
|
|
84
|
+
for (const event of reactions.values()) {
|
|
85
|
+
const values = groupedEvents.get(event.messageId) ?? [];
|
|
86
|
+
values.push({ ...event, threadId: input.threadId });
|
|
87
|
+
groupedEvents.set(event.messageId, values);
|
|
88
|
+
}
|
|
89
|
+
for (const values of groupedEvents.values())
|
|
90
|
+
values.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : a.emojiId < b.emojiId ? -1 : a.emojiId > b.emojiId ? 1 : 0);
|
|
91
|
+
const grouped = new Map();
|
|
92
|
+
for (const event of reactions.values())
|
|
93
|
+
if (event.active) {
|
|
94
|
+
if (!grouped.has(event.messageId))
|
|
95
|
+
grouped.set(event.messageId, new Map());
|
|
96
|
+
const emojis = grouped.get(event.messageId);
|
|
97
|
+
if (!emojis.has(event.emojiId))
|
|
98
|
+
emojis.set(event.emojiId, new Set());
|
|
99
|
+
emojis.get(event.emojiId).add(event.from);
|
|
100
|
+
}
|
|
101
|
+
const previous = new Map(input.previous?.map(item => [item.id, item]) ?? []);
|
|
102
|
+
const result = [];
|
|
103
|
+
for (const { message, status } of records.values()) {
|
|
104
|
+
const envelope = message;
|
|
105
|
+
if (isControl(envelope, input.appId))
|
|
106
|
+
continue;
|
|
107
|
+
const mine = envelope.from === input.profileId, reading = input.reading[message.id];
|
|
108
|
+
const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
|
|
109
|
+
const candidate = input.candidates?.get(message.id);
|
|
110
|
+
const item = { id: message.id, threadId: message.threadId, timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading ? (reading.read ? "read" : status === "read" ? "sent" : status) : status, recalled: withdrawn, ...(candidate ? { unread: !mine && !withdrawn && (reading ? !reading.read : candidate.unread), attentionEligible: candidate.attentionEligible } : {}),
|
|
111
|
+
message: withdrawn ? undefined : message, reading, reactionEvents: withdrawn ? [] : groupedEvents.get(message.id) ?? [], reactions: withdrawn ? [] : [...(grouped.get(message.id) ?? [])].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([emojiId, users]) => { const userIds = [...users].sort(); return { emojiId, userIds, mine: userIds.includes(input.profileId) }; }) };
|
|
112
|
+
const old = previous.get(item.id);
|
|
113
|
+
const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
|
|
114
|
+
const sameReactions = old?.reactions.length === item.reactions.length && old.reactions.every((reaction, index) => { const next = item.reactions[index]; return reaction.emojiId === next.emojiId && reaction.mine === next.mine && reaction.userIds.length === next.userIds.length && reaction.userIds.every((user, i) => user === next.userIds[i]); });
|
|
115
|
+
const sameEvents = old?.reactionEvents.length === item.reactionEvents.length && old.reactionEvents.every((event, index) => { const next = item.reactionEvents[index]; return event.id === next.id && event.from === next.from && event.emojiId === next.emojiId && event.active === next.active && event.revision === next.revision && event.timestamp === next.timestamp && event.threadId === next.threadId && event.messageId === next.messageId; });
|
|
116
|
+
result.push(old && sameEvents && old.message === item.message && old.timestamp === item.timestamp && old.from === item.from && old.mine === item.mine && old.status === item.status && old.recalled === item.recalled && old.unread === item.unread && old.attentionEligible === item.attentionEligible && sameReading && sameReactions ? old : item);
|
|
117
|
+
}
|
|
118
|
+
result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
119
|
+
return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
|
|
120
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
1
3
|
export type ReadCountsResult = {
|
|
2
4
|
appId: string;
|
|
3
5
|
threadId: string;
|
|
@@ -11,6 +13,8 @@ export type ReadCountsOptions = {
|
|
|
11
13
|
clientInstanceId: string;
|
|
12
14
|
hubHttpUrl: string;
|
|
13
15
|
fetch?: typeof fetch;
|
|
16
|
+
scope?: MessageScope;
|
|
17
|
+
access?: ResponseCacheAccess;
|
|
14
18
|
onChange: (value: ReadCountsResult) => void;
|
|
15
19
|
onError?: (error: unknown) => void;
|
|
16
20
|
};
|
|
@@ -25,13 +29,22 @@ export declare class ReadCountsClient {
|
|
|
25
29
|
private generation;
|
|
26
30
|
private retries;
|
|
27
31
|
private closed;
|
|
32
|
+
private closing?;
|
|
28
33
|
private revision;
|
|
29
34
|
private running;
|
|
30
35
|
private dirty;
|
|
31
36
|
private subscribed;
|
|
32
37
|
private subscriptionDenied;
|
|
33
38
|
private subscriptionPending?;
|
|
39
|
+
private cache?;
|
|
40
|
+
private lastResult?;
|
|
41
|
+
private publish;
|
|
42
|
+
private revoked;
|
|
43
|
+
private accessVersion;
|
|
44
|
+
private requestedRevision;
|
|
45
|
+
private force;
|
|
34
46
|
constructor(options: ReadCountsOptions);
|
|
47
|
+
private denyCache;
|
|
35
48
|
private post;
|
|
36
49
|
private subscription;
|
|
37
50
|
private renew;
|
|
@@ -40,5 +53,6 @@ export declare class ReadCountsClient {
|
|
|
40
53
|
reconnect(): void;
|
|
41
54
|
private schedule;
|
|
42
55
|
private refresh;
|
|
43
|
-
|
|
56
|
+
retireScope(version: number): Promise<void>;
|
|
57
|
+
close(): Promise<void>;
|
|
44
58
|
}
|