@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.
@@ -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 (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
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 this.withEpoch("rw", async () => { await this.outbox.put(record); });
259
- await this.checkAccess();
260
- this.values.set(record.hash, record);
261
- this.emit();
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
- if (!this.closed && !this.withdrawn) {
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();
@@ -57,6 +57,7 @@ export declare class SocialReadClient {
57
57
  limit?: number;
58
58
  before?: string;
59
59
  after?: string;
60
+ messageId?: string;
60
61
  }, signal?: AbortSignal): Promise<SocialMessagePage>;
61
62
  group(input: {
62
63
  groupId: string;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.53",
3
+ "version": "0.12.0-next.55",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",