@pmate/chat-sdk 0.12.0-next.53 → 0.12.0-next.55
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/chatSession.d.ts +1 -0
- package/lib/runtime/chatSession.js +15 -1
- package/lib/runtime/createChat.js +13 -0
- package/lib/runtime/durableSender.d.ts +12 -0
- package/lib/runtime/durableSender.js +167 -9
- package/lib/runtime/messageClient.d.ts +3 -0
- package/lib/runtime/messageClient.js +15 -0
- package/lib/runtime/socialReadClient.d.ts +1 -0
- package/lib/runtime/socialReadClient.js +6 -2
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
|
|
|
17
17
|
sharedConnection?: ChatClient;
|
|
18
18
|
activeThreadId?: string;
|
|
19
19
|
uploadVoice?: DurableSenderOptions["uploadVoice"];
|
|
20
|
+
reconcileOutgoing?: DurableSenderOptions["reconcile"];
|
|
20
21
|
reading: ReadingClientOptions;
|
|
21
22
|
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
22
23
|
readingCandidate?: (message: T) => ReadingCandidate | undefined;
|
|
@@ -69,7 +69,21 @@ export class ChatSession {
|
|
|
69
69
|
} });
|
|
70
70
|
this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
|
|
71
71
|
this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
|
|
72
|
-
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice
|
|
72
|
+
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
|
|
73
|
+
const cached = await this.messages.cachedMessage(record.threadId, record.message.hash);
|
|
74
|
+
if (cached && cached.from === scope.profileId && cached.to === record.message.to)
|
|
75
|
+
return true;
|
|
76
|
+
if (!options.reconcileOutgoing)
|
|
77
|
+
throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
|
|
78
|
+
try {
|
|
79
|
+
return await options.reconcileOutgoing(record, signal);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
83
|
+
await this.messages.revoke(record.threadId);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
} });
|
|
73
87
|
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
74
88
|
options.onError(error); });
|
|
75
89
|
this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { voiceUploader } from "./voiceUpload.js";
|
|
2
|
+
import { isOffline } from "./connectivity.js";
|
|
2
3
|
import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
|
|
3
4
|
import { ChatSession } from "./chatSession.js";
|
|
4
5
|
import { SocialReadClient } from "./socialReadClient.js";
|
|
@@ -19,6 +20,18 @@ export function createChat(options) {
|
|
|
19
20
|
return new ChatSession({
|
|
20
21
|
sharedConnection: options.connection,
|
|
21
22
|
uploadVoice: voiceUploader(profileId, options.uploadEndpoint, options.fetch),
|
|
23
|
+
reconcileOutgoing: async (record, signal) => {
|
|
24
|
+
if (isOffline())
|
|
25
|
+
throw new Error("OUTBOX_RECONCILIATION_OFFLINE");
|
|
26
|
+
const result = await reader.messagePage({ threadId: record.threadId, teamId, messageId: record.message.hash }, signal);
|
|
27
|
+
const expected = record.message.body;
|
|
28
|
+
return result.messages.some(message => {
|
|
29
|
+
const body = message.body;
|
|
30
|
+
if (message.from !== profileId || message.to !== record.message.to || body?.kind !== expected.kind || body?.action !== expected.action)
|
|
31
|
+
throw new Error("OUTBOX_RECONCILIATION_MISMATCH");
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
},
|
|
22
35
|
activeThreadId: options.threadId,
|
|
23
36
|
messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
24
37
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
@@ -31,6 +31,7 @@ export type DurableSenderOptions = {
|
|
|
31
31
|
name?: string;
|
|
32
32
|
access?: () => Promise<SenderScopeAccess>;
|
|
33
33
|
uploadVoice?: (blob: Blob, signal: AbortSignal) => Promise<string>;
|
|
34
|
+
reconcile?: (record: Pick<OutgoingMessage, "threadId" | "message">, signal: AbortSignal) => Promise<boolean>;
|
|
34
35
|
};
|
|
35
36
|
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
36
37
|
export declare class DurableChatSender {
|
|
@@ -43,6 +44,13 @@ export declare class DurableChatSender {
|
|
|
43
44
|
private localUrls;
|
|
44
45
|
private localViews;
|
|
45
46
|
private uploads;
|
|
47
|
+
private reconciliations;
|
|
48
|
+
private firstDelivery;
|
|
49
|
+
private acceptanceQueue;
|
|
50
|
+
private acceptanceTimer?;
|
|
51
|
+
private acceptanceRunning?;
|
|
52
|
+
private acceptanceCount;
|
|
53
|
+
private acceptanceBytes;
|
|
46
54
|
private uploadTail;
|
|
47
55
|
private uploadWaiting;
|
|
48
56
|
private confirmationQueue;
|
|
@@ -85,8 +93,12 @@ export declare class DurableChatSender {
|
|
|
85
93
|
/** Compatibility API: resolves only after transport delivery. */
|
|
86
94
|
send(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
|
|
87
95
|
private persistOutgoing;
|
|
96
|
+
private scheduleAcceptances;
|
|
97
|
+
private failAcceptances;
|
|
98
|
+
private drainAcceptances;
|
|
88
99
|
retry(hash: string): Promise<ChatRuntimeMessage>;
|
|
89
100
|
private transmit;
|
|
101
|
+
private reconcile;
|
|
90
102
|
private releaseUrl;
|
|
91
103
|
private releaseUrls;
|
|
92
104
|
private localView;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
3
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
2
4
|
import { withChatTeam } from "./teamScope.js";
|
|
3
5
|
import { messageScopeKey } from "./messageStore.js";
|
|
4
6
|
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
@@ -12,6 +14,13 @@ export class DurableChatSender {
|
|
|
12
14
|
localUrls = new Map();
|
|
13
15
|
localViews = new WeakMap();
|
|
14
16
|
uploads = new Set();
|
|
17
|
+
reconciliations = new Set();
|
|
18
|
+
firstDelivery = new Set();
|
|
19
|
+
acceptanceQueue = [];
|
|
20
|
+
acceptanceTimer;
|
|
21
|
+
acceptanceRunning;
|
|
22
|
+
acceptanceCount = 0;
|
|
23
|
+
acceptanceBytes = 0;
|
|
15
24
|
uploadTail = Promise.resolve();
|
|
16
25
|
uploadWaiting = 0;
|
|
17
26
|
confirmationQueue = [];
|
|
@@ -185,8 +194,11 @@ export class DurableChatSender {
|
|
|
185
194
|
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
186
195
|
withdraw() {
|
|
187
196
|
this.withdrawn = true;
|
|
197
|
+
this.failAcceptances(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
188
198
|
this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
189
199
|
this.uploads.forEach(controller => controller.abort());
|
|
200
|
+
this.reconciliations.forEach(controller => controller.abort());
|
|
201
|
+
this.firstDelivery.clear();
|
|
190
202
|
this.releaseUrls();
|
|
191
203
|
this.values.clear();
|
|
192
204
|
this.emit();
|
|
@@ -243,27 +255,112 @@ export class DurableChatSender {
|
|
|
243
255
|
persistOutgoing(threadId, input) {
|
|
244
256
|
if (this.closed || this.closing || this.withdrawn)
|
|
245
257
|
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
258
|
+
if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
|
|
259
|
+
return Promise.reject(new Error("INVALID_VOICE_ATTACHMENT"));
|
|
260
|
+
const bytes = input.voiceBlob?.size ?? 0;
|
|
261
|
+
if (this.acceptanceCount >= 4096 || this.acceptanceBytes + bytes > 64 * 1024 * 1024)
|
|
262
|
+
return Promise.reject(new Error("OUTBOX_ACCEPTANCE_BACKPRESSURE"));
|
|
263
|
+
this.acceptanceCount++;
|
|
264
|
+
this.acceptanceBytes += bytes;
|
|
246
265
|
const pending = (async () => {
|
|
247
266
|
await this.ready;
|
|
248
267
|
await this.checkAccess();
|
|
249
268
|
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
250
269
|
if (message.from !== this.identity.profileId)
|
|
251
270
|
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
271
|
+
const command = statefulGroup(message);
|
|
272
|
+
if (command)
|
|
273
|
+
message.hash = commandId(this.scope, this.version, threadId, message);
|
|
252
274
|
if (input.voiceBlob) {
|
|
253
275
|
const body = message.body;
|
|
254
|
-
if (
|
|
276
|
+
if (body.kind !== "message" || !validVoice(body.content.voice))
|
|
255
277
|
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
256
278
|
}
|
|
257
279
|
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 };
|
|
258
|
-
await
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
return message;
|
|
263
|
-
})().finally(() => this.accepted.delete(pending));
|
|
280
|
+
const durable = await new Promise((resolve, reject) => {
|
|
281
|
+
this.acceptanceQueue.push({ record, resolve, reject });
|
|
282
|
+
this.scheduleAcceptances();
|
|
283
|
+
});
|
|
284
|
+
return durable.message;
|
|
285
|
+
})().finally(() => { this.accepted.delete(pending); this.acceptanceCount--; this.acceptanceBytes -= bytes; });
|
|
264
286
|
this.accepted.add(pending);
|
|
265
287
|
return pending;
|
|
266
288
|
}
|
|
289
|
+
scheduleAcceptances() {
|
|
290
|
+
if (this.acceptanceTimer || this.acceptanceRunning || !this.acceptanceQueue.length)
|
|
291
|
+
return;
|
|
292
|
+
this.acceptanceTimer = setTimeout(() => { this.acceptanceTimer = undefined; void this.drainAcceptances(); }, 16);
|
|
293
|
+
}
|
|
294
|
+
failAcceptances(error) {
|
|
295
|
+
clearTimeout(this.acceptanceTimer);
|
|
296
|
+
this.acceptanceTimer = undefined;
|
|
297
|
+
for (const item of this.acceptanceQueue.splice(0))
|
|
298
|
+
item.reject(error);
|
|
299
|
+
}
|
|
300
|
+
drainAcceptances() {
|
|
301
|
+
const task = (async () => {
|
|
302
|
+
let bytes = 0, count = 0;
|
|
303
|
+
for (const item of this.acceptanceQueue) {
|
|
304
|
+
const size = item.record.voiceData?.bytes.byteLength ?? 0;
|
|
305
|
+
if (count >= 128 || (count > 0 && bytes + size > 16 * 1024 * 1024))
|
|
306
|
+
break;
|
|
307
|
+
bytes += size;
|
|
308
|
+
count++;
|
|
309
|
+
}
|
|
310
|
+
const batch = this.acceptanceQueue.splice(0, count);
|
|
311
|
+
try {
|
|
312
|
+
const result = await this.withEpoch("rw", async () => {
|
|
313
|
+
const commands = batch.filter(item => statefulGroup(item.record.message));
|
|
314
|
+
const previous = await this.outbox.bulkGet(commands.map(item => [this.scope, item.record.hash]));
|
|
315
|
+
const known = new Map(previous.filter((row) => Boolean(row && (row.accessVersion ?? 0) === this.version)).map(row => [row.hash, row]));
|
|
316
|
+
const writes = [], records = [];
|
|
317
|
+
for (const { record } of batch) {
|
|
318
|
+
const command = statefulGroup(record.message), old = command ? known.get(record.hash) : undefined;
|
|
319
|
+
if (old) {
|
|
320
|
+
if (old.message.from !== this.identity.profileId || old.message.hash !== record.hash || commandId(this.scope, this.version, record.threadId, old.message) !== record.hash)
|
|
321
|
+
throw new Error("OUTGOING_COMMAND_CONFLICT");
|
|
322
|
+
records.push(old);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
writes.push(record);
|
|
326
|
+
records.push(record);
|
|
327
|
+
if (command)
|
|
328
|
+
known.set(record.hash, record);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (writes.length)
|
|
332
|
+
await this.outbox.bulkPut(writes);
|
|
333
|
+
return { writes, records };
|
|
334
|
+
});
|
|
335
|
+
await this.checkAccess();
|
|
336
|
+
for (const record of result.writes)
|
|
337
|
+
if (statefulGroup(record.message))
|
|
338
|
+
this.firstDelivery.add(record.hash);
|
|
339
|
+
let changed = false;
|
|
340
|
+
for (const record of result.records) {
|
|
341
|
+
if (record.confirmed) {
|
|
342
|
+
this.firstDelivery.delete(record.hash);
|
|
343
|
+
changed = this.values.delete(record.hash) || changed;
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const current = this.values.get(record.hash);
|
|
347
|
+
if (!current || current.status !== record.status || current.accessVersion !== record.accessVersion) {
|
|
348
|
+
this.values.set(record.hash, record);
|
|
349
|
+
changed = true;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (changed)
|
|
354
|
+
this.emit();
|
|
355
|
+
batch.forEach((item, index) => item.resolve(result.records[index]));
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
batch.forEach(item => item.reject(error));
|
|
359
|
+
}
|
|
360
|
+
})().finally(() => { this.acceptanceRunning = undefined; this.scheduleAcceptances(); });
|
|
361
|
+
this.acceptanceRunning = task;
|
|
362
|
+
return task;
|
|
363
|
+
}
|
|
267
364
|
retry(hash) {
|
|
268
365
|
if (this.closed || this.closing || this.withdrawn)
|
|
269
366
|
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
@@ -279,8 +376,10 @@ export class DurableChatSender {
|
|
|
279
376
|
let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
|
|
280
377
|
if (!record || this.closed || this.withdrawn)
|
|
281
378
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
282
|
-
if (record.status === "sent" || record.confirmed)
|
|
379
|
+
if (record.status === "sent" || record.confirmed) {
|
|
380
|
+
this.firstDelivery.delete(hash);
|
|
283
381
|
return record.message;
|
|
382
|
+
}
|
|
284
383
|
const sending = { ...record, status: "sending" };
|
|
285
384
|
if (record.status !== "sending") {
|
|
286
385
|
const latest = await this.save(sending);
|
|
@@ -292,7 +391,15 @@ export class DurableChatSender {
|
|
|
292
391
|
this.values.set(hash, sending);
|
|
293
392
|
this.emit();
|
|
294
393
|
}
|
|
394
|
+
let transmitting = false;
|
|
295
395
|
try {
|
|
396
|
+
if (statefulGroup(record.message) && !this.firstDelivery.delete(hash)) {
|
|
397
|
+
const confirmed = await this.reconcile(record);
|
|
398
|
+
if (confirmed) {
|
|
399
|
+
await this.confirm([hash]);
|
|
400
|
+
return record.message;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
296
403
|
if (hasVoice(record)) {
|
|
297
404
|
record = await this.prepareVoice(record);
|
|
298
405
|
if (record.confirmed || record.status === "sent")
|
|
@@ -301,6 +408,7 @@ export class DurableChatSender {
|
|
|
301
408
|
await this.checkAccess();
|
|
302
409
|
if (this.closing)
|
|
303
410
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
411
|
+
transmitting = true;
|
|
304
412
|
const message = await this.client.sendPrepared(record.message);
|
|
305
413
|
const sent = { ...record, message, status: "sent" };
|
|
306
414
|
const committed = await this.save(sent);
|
|
@@ -314,7 +422,20 @@ export class DurableChatSender {
|
|
|
314
422
|
return message;
|
|
315
423
|
}
|
|
316
424
|
catch (error) {
|
|
317
|
-
|
|
425
|
+
// A failed delivery can already be durable remotely, including the first
|
|
426
|
+
// lost ACK. Proof recovery never sends a second wire in this attempt.
|
|
427
|
+
if (statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
|
|
428
|
+
try {
|
|
429
|
+
if (await this.reconcile(record)) {
|
|
430
|
+
await this.confirm([hash]);
|
|
431
|
+
return record.message;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (verificationError) {
|
|
435
|
+
error = new AggregateError([error, verificationError], "OUTBOX_RECONCILIATION_FAILED");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
|
|
318
439
|
const failed = { ...record, status: "failed" };
|
|
319
440
|
const committed = await this.save(failed);
|
|
320
441
|
if (committed?.confirmed || committed?.status === "sent") {
|
|
@@ -331,6 +452,30 @@ export class DurableChatSender {
|
|
|
331
452
|
throw error;
|
|
332
453
|
}
|
|
333
454
|
}
|
|
455
|
+
async reconcile(record) {
|
|
456
|
+
await this.checkAccess();
|
|
457
|
+
if (this.closing || this.closed || this.withdrawn)
|
|
458
|
+
throw new Error("OUTBOX_RECONCILIATION_ABORTED");
|
|
459
|
+
if (!this.options.reconcile)
|
|
460
|
+
throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
|
|
461
|
+
const controller = new AbortController();
|
|
462
|
+
this.reconciliations.add(controller);
|
|
463
|
+
let abort;
|
|
464
|
+
const stopped = new Promise((_, reject) => { abort = () => reject(new Error("OUTBOX_RECONCILIATION_ABORTED")); controller.signal.addEventListener("abort", abort, { once: true }); });
|
|
465
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
466
|
+
try {
|
|
467
|
+
const confirmed = await Promise.race([this.options.reconcile({ threadId: record.threadId, message: record.message }, controller.signal), stopped]);
|
|
468
|
+
await this.checkAccess();
|
|
469
|
+
if (this.closing || controller.signal.aborted)
|
|
470
|
+
throw new Error("OUTBOX_RECONCILIATION_ABORTED");
|
|
471
|
+
return confirmed;
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
clearTimeout(timer);
|
|
475
|
+
controller.signal.removeEventListener("abort", abort);
|
|
476
|
+
this.reconciliations.delete(controller);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
334
479
|
releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
|
|
335
480
|
URL.revokeObjectURL(value.url);
|
|
336
481
|
this.localUrls.delete(hash);
|
|
@@ -533,6 +678,7 @@ export class DurableChatSender {
|
|
|
533
678
|
async close() {
|
|
534
679
|
this.closing = true;
|
|
535
680
|
this.uploads.forEach(controller => controller.abort());
|
|
681
|
+
this.reconciliations.forEach(controller => controller.abort());
|
|
536
682
|
this.offConnection();
|
|
537
683
|
this.listeners.clear();
|
|
538
684
|
this.channel?.close();
|
|
@@ -545,6 +691,7 @@ export class DurableChatSender {
|
|
|
545
691
|
finally {
|
|
546
692
|
this.closed = true;
|
|
547
693
|
this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
|
|
694
|
+
this.firstDelivery.clear();
|
|
548
695
|
this.releaseUrls();
|
|
549
696
|
this.values.clear();
|
|
550
697
|
this.snapshots.clear();
|
|
@@ -552,6 +699,17 @@ export class DurableChatSender {
|
|
|
552
699
|
}
|
|
553
700
|
}
|
|
554
701
|
}
|
|
702
|
+
function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
|
|
703
|
+
function commandId(scope, version, threadId, message) {
|
|
704
|
+
return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
|
|
705
|
+
}
|
|
706
|
+
function ordered(value) {
|
|
707
|
+
if (Array.isArray(value))
|
|
708
|
+
return value.map(ordered);
|
|
709
|
+
if (value && typeof value === "object")
|
|
710
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, ordered(item)]));
|
|
711
|
+
return value;
|
|
712
|
+
}
|
|
555
713
|
function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
|
|
556
714
|
function withVoiceUrl(message, url) {
|
|
557
715
|
const body = message.body;
|
|
@@ -41,6 +41,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
41
41
|
private allListeners;
|
|
42
42
|
private reads;
|
|
43
43
|
private syncing;
|
|
44
|
+
private completedSyncs;
|
|
44
45
|
private paging;
|
|
45
46
|
private writes;
|
|
46
47
|
private activeWrites;
|
|
@@ -72,6 +73,8 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
72
73
|
allowed: boolean;
|
|
73
74
|
}>;
|
|
74
75
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
76
|
+
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
77
|
+
cachedMessage(thread: string, id: string): Promise<T>;
|
|
75
78
|
subscribe(thread: string, listener: () => void): () => void;
|
|
76
79
|
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
77
80
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
@@ -11,6 +11,7 @@ export class MessageClient {
|
|
|
11
11
|
allListeners = new Set();
|
|
12
12
|
reads = new Map();
|
|
13
13
|
syncing = new Map();
|
|
14
|
+
completedSyncs = new Map();
|
|
14
15
|
paging = new Map();
|
|
15
16
|
writes = new Map();
|
|
16
17
|
activeWrites = new Set();
|
|
@@ -100,6 +101,16 @@ export class MessageClient {
|
|
|
100
101
|
return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
|
|
101
102
|
}
|
|
102
103
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
104
|
+
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
105
|
+
async cachedMessage(thread, id) {
|
|
106
|
+
await this.ensureScope();
|
|
107
|
+
if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
|
|
108
|
+
throw accessRevoked();
|
|
109
|
+
const page = await this.storage.readSelected(thread, [id]);
|
|
110
|
+
if (page.coverage?.revoked || (page.coverage?.scopeVersion ?? 0) !== this.scopeVersion)
|
|
111
|
+
throw accessRevoked();
|
|
112
|
+
return page.messages[0];
|
|
113
|
+
}
|
|
103
114
|
subscribe(thread, listener) {
|
|
104
115
|
let watch = this.watches.get(thread);
|
|
105
116
|
if (!watch) {
|
|
@@ -137,6 +148,7 @@ export class MessageClient {
|
|
|
137
148
|
if (running)
|
|
138
149
|
return running;
|
|
139
150
|
const task = (async () => {
|
|
151
|
+
const completedAtStart = this.completedSyncs.get(thread);
|
|
140
152
|
await this.ensureScope();
|
|
141
153
|
if (this.scopeDenied)
|
|
142
154
|
throw accessRevoked();
|
|
@@ -197,6 +209,7 @@ export class MessageClient {
|
|
|
197
209
|
if (this.scopeDenied || this.revoked.has(thread))
|
|
198
210
|
throw accessRevoked();
|
|
199
211
|
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
212
|
+
&& !this.syncing.has(thread) && this.completedSyncs.get(thread) === completedAtStart
|
|
200
213
|
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
201
214
|
this.invalidate(thread);
|
|
202
215
|
return limit === 100 ? snapshot : result;
|
|
@@ -483,6 +496,7 @@ export class MessageClient {
|
|
|
483
496
|
this.assertNetworkAvailable(thread);
|
|
484
497
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
485
498
|
}
|
|
499
|
+
this.completedSyncs.set(thread, (this.completedSyncs.get(thread) ?? 0) + 1);
|
|
486
500
|
})().catch(async (error) => {
|
|
487
501
|
if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
|
|
488
502
|
this.denyScope();
|
|
@@ -676,6 +690,7 @@ export class MessageClient {
|
|
|
676
690
|
this.channel?.close();
|
|
677
691
|
this.watches.clear();
|
|
678
692
|
this.windowEdges.clear();
|
|
693
|
+
this.completedSyncs.clear();
|
|
679
694
|
this.allListeners.clear();
|
|
680
695
|
this.accessListeners.clear();
|
|
681
696
|
this.storage.close();
|
|
@@ -26,17 +26,21 @@ export class SocialReadClient {
|
|
|
26
26
|
}
|
|
27
27
|
async messagePage(input, signal) {
|
|
28
28
|
this.requireTeam(input.teamId);
|
|
29
|
-
const limit = input.limit ?? 100;
|
|
29
|
+
const limit = input.limit ?? (input.messageId === undefined ? 100 : 1);
|
|
30
30
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500 || (input.before !== undefined && input.after !== undefined))
|
|
31
31
|
throw new SocialReadError(400, "INVALID_MESSAGE_PAGE");
|
|
32
|
+
if (input.messageId !== undefined && (!input.messageId || input.messageId.length > 256 || /[\u0000-\u001f\u007f]/.test(input.messageId) || limit !== 1 || input.before !== undefined || input.after !== undefined))
|
|
33
|
+
throw new SocialReadError(400, "INVALID_MESSAGE_LOOKUP");
|
|
32
34
|
const value = await this.request("messages", { threadId: input.threadId, ...this.teamQuery(input.teamId), limit: String(limit),
|
|
33
|
-
...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }) }, signal);
|
|
35
|
+
...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }), ...(input.messageId === undefined ? {} : { messageId: input.messageId }) }, signal);
|
|
34
36
|
const cursor = (token) => typeof token === "string" && /^mp1:\d+:[0-9a-f]{64}$/.test(token);
|
|
35
37
|
if (!value || !Array.isArray(value.messages) || value.messages.length > limit || !cursor(value.checkpoint)
|
|
36
38
|
|| (value.nextBefore !== null && !cursor(value.nextBefore)) || typeof value.hasMoreChanges !== "boolean" || typeof value.resetRequired !== "boolean"
|
|
37
39
|
|| value.messages.some(row => row.appId !== this.options.appId || row.threadId !== input.threadId || row.teamId !== input.teamId || !row.messageId || !Number.isFinite(row.serverCreatedAt))
|
|
38
40
|
|| (value.resetRequired && (value.messages.length || value.hasMoreChanges || value.nextBefore !== null)))
|
|
39
41
|
throw new SocialReadError(502, "INVALID_MESSAGE_PAGE_RESPONSE");
|
|
42
|
+
if (input.messageId !== undefined && (value.messages.some(row => row.messageId !== input.messageId) || value.nextBefore !== null || value.hasMoreChanges || value.resetRequired))
|
|
43
|
+
throw new SocialReadError(502, "INVALID_MESSAGE_LOOKUP_RESPONSE");
|
|
40
44
|
return value;
|
|
41
45
|
}
|
|
42
46
|
group(input) {
|
package/package.json
CHANGED