@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
|
@@ -5,8 +5,20 @@ import { messageScopeKey } from "./messageStore.js";
|
|
|
5
5
|
export class DurableChatSender {
|
|
6
6
|
client;
|
|
7
7
|
onError;
|
|
8
|
+
options;
|
|
8
9
|
db;
|
|
9
10
|
outbox;
|
|
11
|
+
epochs;
|
|
12
|
+
localUrls = new Map();
|
|
13
|
+
localViews = new WeakMap();
|
|
14
|
+
uploads = new Set();
|
|
15
|
+
uploadTail = Promise.resolve();
|
|
16
|
+
uploadWaiting = 0;
|
|
17
|
+
version = 0;
|
|
18
|
+
withdrawn = false;
|
|
19
|
+
revoking;
|
|
20
|
+
channel;
|
|
21
|
+
accepted = new Set();
|
|
10
22
|
scope;
|
|
11
23
|
identity;
|
|
12
24
|
values = new Map();
|
|
@@ -14,33 +26,197 @@ export class DurableChatSender {
|
|
|
14
26
|
listeners = new Set();
|
|
15
27
|
running = new Map();
|
|
16
28
|
closed = false;
|
|
29
|
+
closing = false;
|
|
30
|
+
empty = [];
|
|
17
31
|
offConnection;
|
|
18
32
|
ready;
|
|
19
33
|
constructor(client, scope, onError, options = {}) {
|
|
20
34
|
this.client = client;
|
|
21
35
|
this.onError = onError;
|
|
36
|
+
this.options = options;
|
|
22
37
|
this.scope = messageScopeKey(scope);
|
|
23
38
|
this.identity = { ...scope };
|
|
24
39
|
this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
40
|
this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
41
|
+
this.db.version(2).stores({ scopeEpochs: "scope" });
|
|
42
|
+
this.db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
26
43
|
this.outbox = this.db.table("outbox");
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
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 }));
|
|
47
|
+
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
48
|
+
this.ready = this.restore();
|
|
49
|
+
void this.ready.catch(() => { });
|
|
50
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
51
|
+
this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
|
|
52
|
+
this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
|
|
53
|
+
this.withdraw(); };
|
|
54
|
+
}
|
|
33
55
|
this.offConnection = client.watchConnection(state => {
|
|
34
56
|
if (state === "connected")
|
|
35
57
|
void this.resume().catch(onError);
|
|
36
58
|
});
|
|
37
59
|
}
|
|
60
|
+
/** Read only the selected durable rows, without restoring or delivering an outbox. */
|
|
61
|
+
static async readSelected(scope, threadId, ids, version, options = {}) {
|
|
62
|
+
if (ids.length > 100)
|
|
63
|
+
throw new Error("INVALID_OUTBOX_SELECTION");
|
|
64
|
+
const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
65
|
+
db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
66
|
+
db.version(2).stores({ scopeEpochs: "scope" });
|
|
67
|
+
db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
68
|
+
try {
|
|
69
|
+
return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
|
|
70
|
+
if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
|
|
71
|
+
return [];
|
|
72
|
+
const rows = await db.table("outbox").bulkGet(ids.map(id => [key, id]));
|
|
73
|
+
return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [row] : []);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
db.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async readAccess() {
|
|
81
|
+
const access = await this.options.access?.();
|
|
82
|
+
if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
|
|
83
|
+
throw new Error("INVALID_OUTBOX_ACCESS");
|
|
84
|
+
return access;
|
|
85
|
+
}
|
|
86
|
+
async restore() {
|
|
87
|
+
const access = await this.readAccess();
|
|
88
|
+
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
89
|
+
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
90
|
+
const version = access?.version ?? current;
|
|
91
|
+
if (version < current)
|
|
92
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
93
|
+
if (version > current) {
|
|
94
|
+
await this.epochs.put({ scope: this.scope, version });
|
|
95
|
+
await this.deleteRows();
|
|
96
|
+
}
|
|
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 };
|
|
100
|
+
});
|
|
101
|
+
this.version = loaded.version;
|
|
102
|
+
if (loaded.advanced)
|
|
103
|
+
this.channel?.postMessage({ version: this.version });
|
|
104
|
+
if (this.closed || this.withdrawn)
|
|
105
|
+
return;
|
|
106
|
+
const current = await this.readAccess();
|
|
107
|
+
if (current && (!current.allowed || current.version !== this.version))
|
|
108
|
+
return;
|
|
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;
|
|
148
|
+
this.emit();
|
|
149
|
+
}
|
|
150
|
+
async checkAccess() {
|
|
151
|
+
if (this.closed)
|
|
152
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
153
|
+
if (this.withdrawn)
|
|
154
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
155
|
+
const access = await this.readAccess();
|
|
156
|
+
if (access && access.version !== this.version) {
|
|
157
|
+
this.withdraw();
|
|
158
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
159
|
+
}
|
|
160
|
+
if (access && !access.allowed)
|
|
161
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
162
|
+
}
|
|
163
|
+
async withEpoch(mode, operation) {
|
|
164
|
+
await this.checkAccess();
|
|
165
|
+
try {
|
|
166
|
+
return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
|
|
167
|
+
if (this.closed || this.withdrawn)
|
|
168
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
169
|
+
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
170
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
171
|
+
return operation();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
176
|
+
this.withdraw();
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
deleteRows() { return this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete(); }
|
|
181
|
+
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
182
|
+
withdraw() {
|
|
183
|
+
this.withdrawn = true;
|
|
184
|
+
this.uploads.forEach(controller => controller.abort());
|
|
185
|
+
this.releaseUrls();
|
|
186
|
+
this.values.clear();
|
|
187
|
+
this.emit();
|
|
188
|
+
}
|
|
189
|
+
revoke(targetVersion) {
|
|
190
|
+
if (this.closed || this.closing)
|
|
191
|
+
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
192
|
+
if (this.revoking)
|
|
193
|
+
return this.revoking;
|
|
194
|
+
this.withdraw();
|
|
195
|
+
return this.revoking = (async () => {
|
|
196
|
+
await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
197
|
+
throw error; });
|
|
198
|
+
const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
199
|
+
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
200
|
+
const target = targetVersion ?? this.version + 1;
|
|
201
|
+
if (!Number.isSafeInteger(target) || target < 0)
|
|
202
|
+
throw new Error("INVALID_OUTBOX_EPOCH");
|
|
203
|
+
if (current >= target)
|
|
204
|
+
return current;
|
|
205
|
+
await this.epochs.put({ scope: this.scope, version: target });
|
|
206
|
+
await this.deleteRows();
|
|
207
|
+
return target;
|
|
208
|
+
});
|
|
209
|
+
this.channel?.postMessage({ version });
|
|
210
|
+
})();
|
|
211
|
+
}
|
|
38
212
|
initialize() { return this.ready; }
|
|
39
213
|
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
40
214
|
snapshot(threadId) {
|
|
215
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
216
|
+
return this.empty;
|
|
41
217
|
let snapshot = this.snapshots.get(threadId);
|
|
42
218
|
if (!snapshot) {
|
|
43
|
-
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
|
|
219
|
+
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId).map(record => this.localView(record));
|
|
44
220
|
this.snapshots.set(threadId, snapshot);
|
|
45
221
|
}
|
|
46
222
|
return snapshot;
|
|
@@ -50,7 +226,8 @@ export class DurableChatSender {
|
|
|
50
226
|
/** Resolves after the exact wire message is durable, independently of network delivery. */
|
|
51
227
|
async enqueue(threadId, input) {
|
|
52
228
|
const message = await this.persistOutgoing(threadId, input);
|
|
53
|
-
|
|
229
|
+
if (!this.closed && !this.closing && !this.withdrawn)
|
|
230
|
+
void this.retry(message.hash).catch(error => this.onError(error));
|
|
54
231
|
return message;
|
|
55
232
|
}
|
|
56
233
|
/** Compatibility API: resolves only after transport delivery. */
|
|
@@ -58,23 +235,32 @@ export class DurableChatSender {
|
|
|
58
235
|
const message = await this.persistOutgoing(threadId, input);
|
|
59
236
|
return this.retry(message.hash);
|
|
60
237
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
238
|
+
persistOutgoing(threadId, input) {
|
|
239
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
240
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
241
|
+
const pending = (async () => {
|
|
242
|
+
await this.ready;
|
|
243
|
+
await this.checkAccess();
|
|
244
|
+
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
245
|
+
if (message.from !== this.identity.profileId)
|
|
246
|
+
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
247
|
+
if (input.voiceBlob) {
|
|
248
|
+
const body = message.body;
|
|
249
|
+
if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
|
|
250
|
+
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
251
|
+
}
|
|
252
|
+
const record = { ...(input.voiceBlob ? { voiceData: { bytes: await input.voiceBlob.arrayBuffer(), type: input.voiceBlob.type } } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
|
|
253
|
+
await this.withEpoch("rw", async () => { await this.outbox.put(record); });
|
|
254
|
+
await this.checkAccess();
|
|
255
|
+
this.values.set(record.hash, record);
|
|
256
|
+
this.emit();
|
|
257
|
+
return message;
|
|
258
|
+
})().finally(() => this.accepted.delete(pending));
|
|
259
|
+
this.accepted.add(pending);
|
|
260
|
+
return pending;
|
|
75
261
|
}
|
|
76
262
|
retry(hash) {
|
|
77
|
-
if (this.closed)
|
|
263
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
78
264
|
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
79
265
|
const running = this.running.get(hash);
|
|
80
266
|
if (running)
|
|
@@ -85,8 +271,8 @@ export class DurableChatSender {
|
|
|
85
271
|
}
|
|
86
272
|
async transmit(hash) {
|
|
87
273
|
await this.ready;
|
|
88
|
-
|
|
89
|
-
if (!record || this.closed)
|
|
274
|
+
let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
|
|
275
|
+
if (!record || this.closed || this.withdrawn)
|
|
90
276
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
91
277
|
if (record.status === "sent" || record.confirmed)
|
|
92
278
|
return record.message;
|
|
@@ -96,15 +282,24 @@ export class DurableChatSender {
|
|
|
96
282
|
if (!latest || latest.confirmed || latest.status === "sent")
|
|
97
283
|
return latest?.message ?? record.message;
|
|
98
284
|
}
|
|
285
|
+
await this.checkAccess();
|
|
99
286
|
if (this.values.get(hash)?.status !== "sending") {
|
|
100
287
|
this.values.set(hash, sending);
|
|
101
288
|
this.emit();
|
|
102
289
|
}
|
|
103
290
|
try {
|
|
291
|
+
if (hasVoice(record)) {
|
|
292
|
+
record = await this.prepareVoice(record);
|
|
293
|
+
if (record.confirmed || record.status === "sent")
|
|
294
|
+
return record.message;
|
|
295
|
+
}
|
|
296
|
+
await this.checkAccess();
|
|
297
|
+
if (this.closing)
|
|
298
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
104
299
|
const message = await this.client.sendPrepared(record.message);
|
|
105
300
|
const sent = { ...record, message, status: "sent" };
|
|
106
301
|
const committed = await this.save(sent);
|
|
107
|
-
if (!this.closed) {
|
|
302
|
+
if (!this.closed && !this.withdrawn) {
|
|
108
303
|
if (committed?.confirmed || !committed)
|
|
109
304
|
this.values.delete(hash);
|
|
110
305
|
else
|
|
@@ -114,7 +309,7 @@ export class DurableChatSender {
|
|
|
114
309
|
return message;
|
|
115
310
|
}
|
|
116
311
|
catch (error) {
|
|
117
|
-
if (!this.closed) {
|
|
312
|
+
if (!this.closed && !this.withdrawn) {
|
|
118
313
|
const failed = { ...record, status: "failed" };
|
|
119
314
|
const committed = await this.save(failed);
|
|
120
315
|
if (committed?.confirmed || committed?.status === "sent") {
|
|
@@ -131,11 +326,93 @@ export class DurableChatSender {
|
|
|
131
326
|
throw error;
|
|
132
327
|
}
|
|
133
328
|
}
|
|
329
|
+
releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
|
|
330
|
+
URL.revokeObjectURL(value.url);
|
|
331
|
+
this.localUrls.delete(hash);
|
|
332
|
+
} }
|
|
333
|
+
releaseUrls() { for (const hash of this.localUrls.keys())
|
|
334
|
+
this.releaseUrl(hash); }
|
|
335
|
+
localView(record) {
|
|
336
|
+
if (!hasVoice(record) || typeof URL.createObjectURL !== "function")
|
|
337
|
+
return record;
|
|
338
|
+
let cached = this.localViews.get(record);
|
|
339
|
+
if (cached)
|
|
340
|
+
return cached;
|
|
341
|
+
const blob = voiceBlob(record);
|
|
342
|
+
let local = this.localUrls.get(record.hash);
|
|
343
|
+
if (!local || local.blob !== blob) {
|
|
344
|
+
this.releaseUrl(record.hash);
|
|
345
|
+
local = { blob, url: URL.createObjectURL(blob) };
|
|
346
|
+
this.localUrls.set(record.hash, local);
|
|
347
|
+
}
|
|
348
|
+
cached = { ...record, voiceBlob: blob, message: withVoiceUrl(record.message, local.url) };
|
|
349
|
+
this.localViews.set(record, cached);
|
|
350
|
+
return cached;
|
|
351
|
+
}
|
|
352
|
+
async prepareVoice(record) {
|
|
353
|
+
if (this.uploadWaiting >= 32)
|
|
354
|
+
throw new Error("VOICE_UPLOAD_BACKPRESSURE");
|
|
355
|
+
this.uploadWaiting++;
|
|
356
|
+
const previous = this.uploadTail;
|
|
357
|
+
let release;
|
|
358
|
+
this.uploadTail = new Promise(resolve => { release = resolve; });
|
|
359
|
+
try {
|
|
360
|
+
await previous;
|
|
361
|
+
await this.checkAccess();
|
|
362
|
+
if (this.closing)
|
|
363
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
364
|
+
const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
|
|
365
|
+
if (!latest)
|
|
366
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
367
|
+
if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
|
|
368
|
+
return latest;
|
|
369
|
+
if (!this.options.uploadVoice)
|
|
370
|
+
throw new Error("VOICE_UPLOAD_UNAVAILABLE");
|
|
371
|
+
const controller = new AbortController();
|
|
372
|
+
this.uploads.add(controller);
|
|
373
|
+
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
374
|
+
let url;
|
|
375
|
+
try {
|
|
376
|
+
url = await this.options.uploadVoice(voiceBlob(latest), controller.signal);
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
clearTimeout(timeout);
|
|
380
|
+
this.uploads.delete(controller);
|
|
381
|
+
}
|
|
382
|
+
const parsed = new URL(url);
|
|
383
|
+
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
384
|
+
throw new Error("INVALID_VOICE_UPLOAD_URL");
|
|
385
|
+
await this.checkAccess();
|
|
386
|
+
if (this.closing)
|
|
387
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
388
|
+
const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
|
|
389
|
+
const committed = await this.withEpoch("rw", async () => {
|
|
390
|
+
const current = await this.outbox.get([this.scope, record.hash]);
|
|
391
|
+
if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
|
|
392
|
+
return current;
|
|
393
|
+
const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
|
|
394
|
+
await this.outbox.put(next);
|
|
395
|
+
return next;
|
|
396
|
+
});
|
|
397
|
+
if (!committed)
|
|
398
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
399
|
+
this.values.set(record.hash, committed);
|
|
400
|
+
this.emit();
|
|
401
|
+
return committed;
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
this.uploadWaiting--;
|
|
405
|
+
release();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
134
408
|
async save(record) {
|
|
135
|
-
return this.
|
|
409
|
+
return this.withEpoch("rw", async () => {
|
|
136
410
|
const previous = await this.outbox.get([this.scope, record.hash]);
|
|
137
411
|
if (!previous || previous.confirmed || previous.status === "sent")
|
|
138
412
|
return previous;
|
|
413
|
+
// A second document may already have durably replaced the Blob with its URL.
|
|
414
|
+
if (hasVoice(record) && !hasVoice(previous))
|
|
415
|
+
return previous;
|
|
139
416
|
await this.outbox.put(record);
|
|
140
417
|
return record;
|
|
141
418
|
});
|
|
@@ -147,26 +424,41 @@ export class DurableChatSender {
|
|
|
147
424
|
if (!hashes.length)
|
|
148
425
|
return;
|
|
149
426
|
await this.ready;
|
|
150
|
-
await this.db.transaction("rw", this.outbox, async () => {
|
|
151
|
-
const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
|
|
152
|
-
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
|
|
153
|
-
if (confirmed.length)
|
|
154
|
-
await this.outbox.bulkPut(confirmed);
|
|
155
|
-
});
|
|
156
427
|
let changed = false;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
428
|
+
try {
|
|
429
|
+
for (let offset = 0; offset < hashes.length; offset += 128) {
|
|
430
|
+
const batch = hashes.slice(offset, offset + 128);
|
|
431
|
+
await this.withEpoch("rw", async () => {
|
|
432
|
+
const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
|
|
433
|
+
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
|
|
434
|
+
if (confirmed.length)
|
|
435
|
+
await this.outbox.bulkPut(confirmed);
|
|
436
|
+
});
|
|
437
|
+
// Retire only committed rows. A later failed transaction leaves the
|
|
438
|
+
// remaining sends retryable, including after a browser restart.
|
|
439
|
+
for (const hash of batch) {
|
|
440
|
+
changed = this.values.delete(hash) || changed;
|
|
441
|
+
this.releaseUrl(hash);
|
|
442
|
+
}
|
|
443
|
+
if (offset + batch.length < hashes.length)
|
|
444
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
finally {
|
|
448
|
+
// One confirmation operation publishes at most once, also on partial
|
|
449
|
+
// failure so consumers see exactly the progress that was committed.
|
|
450
|
+
if (changed)
|
|
451
|
+
this.emit();
|
|
452
|
+
}
|
|
161
453
|
}
|
|
162
454
|
async resume() {
|
|
163
455
|
await this.ready;
|
|
164
|
-
if (this.closed)
|
|
456
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
165
457
|
return;
|
|
166
458
|
// Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
|
|
167
459
|
for (const record of this.values.values())
|
|
168
460
|
if (record.status === "sending") {
|
|
169
|
-
if (this.closed)
|
|
461
|
+
if (this.closed || this.withdrawn)
|
|
170
462
|
return;
|
|
171
463
|
try {
|
|
172
464
|
await this.retry(record.hash);
|
|
@@ -177,11 +469,29 @@ export class DurableChatSender {
|
|
|
177
469
|
}
|
|
178
470
|
}
|
|
179
471
|
async close() {
|
|
180
|
-
this.
|
|
472
|
+
this.closing = true;
|
|
473
|
+
this.uploads.forEach(controller => controller.abort());
|
|
181
474
|
this.offConnection();
|
|
182
475
|
this.listeners.clear();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
476
|
+
this.channel?.close();
|
|
477
|
+
this.channel = undefined;
|
|
478
|
+
try {
|
|
479
|
+
await this.ready;
|
|
480
|
+
await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
this.closed = true;
|
|
484
|
+
this.releaseUrls();
|
|
485
|
+
this.values.clear();
|
|
486
|
+
this.snapshots.clear();
|
|
487
|
+
this.db.close();
|
|
488
|
+
}
|
|
186
489
|
}
|
|
187
490
|
}
|
|
491
|
+
function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
|
|
492
|
+
function withVoiceUrl(message, url) {
|
|
493
|
+
const body = message.body;
|
|
494
|
+
return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
|
|
495
|
+
}
|
|
496
|
+
function hasVoice(record) { return Boolean(record.voiceBlob || record.voiceData); }
|
|
497
|
+
function voiceBlob(record) { return record.voiceBlob ?? new Blob([record.voiceData.bytes], { type: record.voiceData.type }); }
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -29,3 +29,7 @@ export * from "./durableSender.js";
|
|
|
29
29
|
export * from "./chatSession.js";
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
|
+
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
|
34
|
+
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -29,3 +29,7 @@ export * from "./durableSender.js";
|
|
|
29
29
|
export * from "./chatSession.js";
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
|
+
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading } from "./conversationReading.js";
|
|
34
|
+
export { projectMessageTimeline } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
|
@@ -36,6 +36,9 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
36
36
|
private options;
|
|
37
37
|
private storage;
|
|
38
38
|
private watches;
|
|
39
|
+
private windowEdges;
|
|
40
|
+
private accessListeners;
|
|
41
|
+
private allListeners;
|
|
39
42
|
private reads;
|
|
40
43
|
private syncing;
|
|
41
44
|
private paging;
|
|
@@ -53,12 +56,32 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
53
56
|
private queueBytes;
|
|
54
57
|
private pendingBytes;
|
|
55
58
|
private pendingRecords;
|
|
59
|
+
private legacyImports;
|
|
60
|
+
private revokingScope?;
|
|
61
|
+
private scopeReady?;
|
|
62
|
+
private scopeVersion;
|
|
63
|
+
private scopeAllowed;
|
|
64
|
+
private scopeDenied;
|
|
65
|
+
private enqueuing;
|
|
66
|
+
private ensureScope;
|
|
56
67
|
constructor(options: MessageClientOptions<T>);
|
|
68
|
+
/** Shared guard for sibling caches; it never authorizes a denied old instance. */
|
|
69
|
+
subscribeAccessRevoked(listener: () => void): () => void;
|
|
70
|
+
scopeAccess(): Promise<{
|
|
71
|
+
version: number;
|
|
72
|
+
allowed: boolean;
|
|
73
|
+
}>;
|
|
57
74
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
58
75
|
subscribe(thread: string, listener: () => void): () => void;
|
|
59
|
-
|
|
76
|
+
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
77
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
78
|
+
restore(since: number, threadId?: string): Promise<void>;
|
|
79
|
+
read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
|
|
60
80
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
61
81
|
invalidate(thread: string): void;
|
|
82
|
+
private fallback;
|
|
83
|
+
/** Migration batches remain separate from authoritative message coverage. */
|
|
84
|
+
importLegacy(thread: string, messages: T[]): Promise<void>;
|
|
62
85
|
private reload;
|
|
63
86
|
/** Events are visible only after their atomic batch commits. */
|
|
64
87
|
receive(thread: string, messages: T[]): Promise<void>;
|
|
@@ -67,17 +90,18 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
67
90
|
private release;
|
|
68
91
|
private flushWrites;
|
|
69
92
|
private runWrites;
|
|
93
|
+
private assertNetworkAvailable;
|
|
70
94
|
synchronize(thread: string): Promise<void>;
|
|
95
|
+
/** Keep an explicitly requested history range in the shared subscribed view. */
|
|
96
|
+
expandWindow(thread: string, page: MessageSnapshot<T>): Promise<void>;
|
|
71
97
|
earlier(thread: string, before: {
|
|
72
98
|
timestamp: number;
|
|
73
99
|
id: string;
|
|
74
|
-
}, limit?: number): Promise<
|
|
75
|
-
messages: T[];
|
|
76
|
-
hasEarlier: boolean;
|
|
77
|
-
resetVersion: number;
|
|
78
|
-
complete: boolean;
|
|
79
|
-
}>;
|
|
100
|
+
}, limit?: number, expand?: boolean): Promise<MessageSnapshot<T>>;
|
|
80
101
|
private fillWindow;
|
|
102
|
+
private denyView;
|
|
103
|
+
private denyScope;
|
|
104
|
+
revokeScope(): Promise<number>;
|
|
81
105
|
revoke(thread: string): Promise<void>;
|
|
82
|
-
close(): Promise<void>;
|
|
106
|
+
close(beforeDispose?: Promise<unknown>): Promise<void>;
|
|
83
107
|
}
|