@pmate/chat-sdk 0.12.0-next.53 → 0.12.0-next.54

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,8 @@ export declare class DurableChatSender {
43
44
  private localUrls;
44
45
  private localViews;
45
46
  private uploads;
47
+ private reconciliations;
48
+ private firstDelivery;
46
49
  private uploadTail;
47
50
  private uploadWaiting;
48
51
  private confirmationQueue;
@@ -87,6 +90,7 @@ export declare class DurableChatSender {
87
90
  private persistOutgoing;
88
91
  retry(hash: string): Promise<ChatRuntimeMessage>;
89
92
  private transmit;
93
+ private reconcile;
90
94
  private releaseUrl;
91
95
  private releaseUrls;
92
96
  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,8 @@ 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();
15
19
  uploadTail = Promise.resolve();
16
20
  uploadWaiting = 0;
17
21
  confirmationQueue = [];
@@ -187,6 +191,8 @@ export class DurableChatSender {
187
191
  this.withdrawn = true;
188
192
  this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
189
193
  this.uploads.forEach(controller => controller.abort());
194
+ this.reconciliations.forEach(controller => controller.abort());
195
+ this.firstDelivery.clear();
190
196
  this.releaseUrls();
191
197
  this.values.clear();
192
198
  this.emit();
@@ -249,17 +255,43 @@ export class DurableChatSender {
249
255
  const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
250
256
  if (message.from !== this.identity.profileId)
251
257
  throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
258
+ const command = statefulGroup(message);
259
+ if (command)
260
+ message.hash = commandId(this.scope, this.version, threadId, message);
252
261
  if (input.voiceBlob) {
253
262
  const body = message.body;
254
263
  if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
255
264
  throw new Error("INVALID_VOICE_ATTACHMENT");
256
265
  }
257
266
  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); });
267
+ const durable = await this.withEpoch("rw", async () => {
268
+ if (command) {
269
+ const previous = await this.outbox.get([this.scope, message.hash]);
270
+ if (previous && (previous.accessVersion ?? 0) === this.version) {
271
+ if (previous.message.from !== this.identity.profileId || previous.message.hash !== message.hash || commandId(this.scope, this.version, threadId, previous.message) !== message.hash)
272
+ throw new Error("OUTGOING_COMMAND_CONFLICT");
273
+ return previous;
274
+ }
275
+ }
276
+ await this.outbox.put(record);
277
+ return record;
278
+ });
259
279
  await this.checkAccess();
260
- this.values.set(record.hash, record);
261
- this.emit();
262
- return message;
280
+ if (command && durable === record)
281
+ this.firstDelivery.add(record.hash);
282
+ if (durable.confirmed) {
283
+ this.firstDelivery.delete(durable.hash);
284
+ if (this.values.delete(durable.hash))
285
+ this.emit();
286
+ }
287
+ else {
288
+ const current = this.values.get(durable.hash);
289
+ if (!current || current.status !== durable.status || current.accessVersion !== durable.accessVersion) {
290
+ this.values.set(durable.hash, durable);
291
+ this.emit();
292
+ }
293
+ }
294
+ return durable.message;
263
295
  })().finally(() => this.accepted.delete(pending));
264
296
  this.accepted.add(pending);
265
297
  return pending;
@@ -279,8 +311,10 @@ export class DurableChatSender {
279
311
  let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
280
312
  if (!record || this.closed || this.withdrawn)
281
313
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
282
- if (record.status === "sent" || record.confirmed)
314
+ if (record.status === "sent" || record.confirmed) {
315
+ this.firstDelivery.delete(hash);
283
316
  return record.message;
317
+ }
284
318
  const sending = { ...record, status: "sending" };
285
319
  if (record.status !== "sending") {
286
320
  const latest = await this.save(sending);
@@ -292,7 +326,15 @@ export class DurableChatSender {
292
326
  this.values.set(hash, sending);
293
327
  this.emit();
294
328
  }
329
+ let transmitting = false;
295
330
  try {
331
+ if (statefulGroup(record.message) && !this.firstDelivery.delete(hash)) {
332
+ const confirmed = await this.reconcile(record);
333
+ if (confirmed) {
334
+ await this.confirm([hash]);
335
+ return record.message;
336
+ }
337
+ }
296
338
  if (hasVoice(record)) {
297
339
  record = await this.prepareVoice(record);
298
340
  if (record.confirmed || record.status === "sent")
@@ -301,6 +343,7 @@ export class DurableChatSender {
301
343
  await this.checkAccess();
302
344
  if (this.closing)
303
345
  throw new Error("CHAT_SENDER_CLOSED");
346
+ transmitting = true;
304
347
  const message = await this.client.sendPrepared(record.message);
305
348
  const sent = { ...record, message, status: "sent" };
306
349
  const committed = await this.save(sent);
@@ -314,7 +357,20 @@ export class DurableChatSender {
314
357
  return message;
315
358
  }
316
359
  catch (error) {
317
- if (!this.closed && !this.withdrawn) {
360
+ // A failed delivery can already be durable remotely, including the first
361
+ // lost ACK. Proof recovery never sends a second wire in this attempt.
362
+ if (statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
363
+ try {
364
+ if (await this.reconcile(record)) {
365
+ await this.confirm([hash]);
366
+ return record.message;
367
+ }
368
+ }
369
+ catch (verificationError) {
370
+ error = new AggregateError([error, verificationError], "OUTBOX_RECONCILIATION_FAILED");
371
+ }
372
+ }
373
+ if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
318
374
  const failed = { ...record, status: "failed" };
319
375
  const committed = await this.save(failed);
320
376
  if (committed?.confirmed || committed?.status === "sent") {
@@ -331,6 +387,30 @@ export class DurableChatSender {
331
387
  throw error;
332
388
  }
333
389
  }
390
+ async reconcile(record) {
391
+ await this.checkAccess();
392
+ if (this.closing || this.closed || this.withdrawn)
393
+ throw new Error("OUTBOX_RECONCILIATION_ABORTED");
394
+ if (!this.options.reconcile)
395
+ throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
396
+ const controller = new AbortController();
397
+ this.reconciliations.add(controller);
398
+ let abort;
399
+ const stopped = new Promise((_, reject) => { abort = () => reject(new Error("OUTBOX_RECONCILIATION_ABORTED")); controller.signal.addEventListener("abort", abort, { once: true }); });
400
+ const timer = setTimeout(() => controller.abort(), 10_000);
401
+ try {
402
+ const confirmed = await Promise.race([this.options.reconcile({ threadId: record.threadId, message: record.message }, controller.signal), stopped]);
403
+ await this.checkAccess();
404
+ if (this.closing || controller.signal.aborted)
405
+ throw new Error("OUTBOX_RECONCILIATION_ABORTED");
406
+ return confirmed;
407
+ }
408
+ finally {
409
+ clearTimeout(timer);
410
+ controller.signal.removeEventListener("abort", abort);
411
+ this.reconciliations.delete(controller);
412
+ }
413
+ }
334
414
  releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
335
415
  URL.revokeObjectURL(value.url);
336
416
  this.localUrls.delete(hash);
@@ -533,6 +613,7 @@ export class DurableChatSender {
533
613
  async close() {
534
614
  this.closing = true;
535
615
  this.uploads.forEach(controller => controller.abort());
616
+ this.reconciliations.forEach(controller => controller.abort());
536
617
  this.offConnection();
537
618
  this.listeners.clear();
538
619
  this.channel?.close();
@@ -545,6 +626,7 @@ export class DurableChatSender {
545
626
  finally {
546
627
  this.closed = true;
547
628
  this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
629
+ this.firstDelivery.clear();
548
630
  this.releaseUrls();
549
631
  this.values.clear();
550
632
  this.snapshots.clear();
@@ -552,6 +634,17 @@ export class DurableChatSender {
552
634
  }
553
635
  }
554
636
  }
637
+ function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
638
+ function commandId(scope, version, threadId, message) {
639
+ return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
640
+ }
641
+ function ordered(value) {
642
+ if (Array.isArray(value))
643
+ return value.map(ordered);
644
+ if (value && typeof value === "object")
645
+ return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, ordered(item)]));
646
+ return value;
647
+ }
555
648
  function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
556
649
  function withVoiceUrl(message, url) {
557
650
  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.54",
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",