@pmate/chat-sdk 0.12.0-next.43 → 0.12.0-next.45

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.
@@ -8,8 +8,13 @@ export type OutgoingMessage = {
8
8
  message: ChatRuntimeMessage;
9
9
  status: "sending" | "failed" | "sent";
10
10
  confirmed?: boolean;
11
+ pendingState?: 0 | 1;
11
12
  accessVersion?: number;
12
13
  voiceBlob?: Blob;
14
+ voiceData?: {
15
+ bytes: ArrayBuffer;
16
+ type: string;
17
+ };
13
18
  };
14
19
  export type SendMessageInput = {
15
20
  to: string;
@@ -39,8 +39,11 @@ export class DurableChatSender {
39
39
  this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
40
40
  this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
41
41
  this.db.version(2).stores({ scopeEpochs: "scope" });
42
+ this.db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
42
43
  this.outbox = this.db.table("outbox");
43
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 }));
44
47
  this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
45
48
  this.ready = this.restore();
46
49
  void this.ready.catch(() => { });
@@ -61,6 +64,7 @@ export class DurableChatSender {
61
64
  const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
62
65
  db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
63
66
  db.version(2).stores({ scopeEpochs: "scope" });
67
+ db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
64
68
  try {
65
69
  return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
66
70
  if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
@@ -90,7 +94,9 @@ export class DurableChatSender {
90
94
  await this.epochs.put({ scope: this.scope, version });
91
95
  await this.deleteRows();
92
96
  }
93
- return { version, advanced: version > current, records: access?.allowed === false ? [] : await this.outbox.where("scope").equals(this.scope).toArray() };
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 };
94
100
  });
95
101
  this.version = loaded.version;
96
102
  if (loaded.advanced)
@@ -100,7 +106,45 @@ export class DurableChatSender {
100
106
  const current = await this.readAccess();
101
107
  if (current && (!current.allowed || current.version !== this.version))
102
108
  return;
103
- loaded.records.filter(record => !record.confirmed && (record.accessVersion ?? 0) === this.version).forEach(record => this.values.set(record.hash, record));
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;
104
148
  this.emit();
105
149
  }
106
150
  async checkAccess() {
@@ -205,7 +249,7 @@ export class DurableChatSender {
205
249
  if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
206
250
  throw new Error("INVALID_VOICE_ATTACHMENT");
207
251
  }
208
- const record = { ...(input.voiceBlob ? { voiceBlob: input.voiceBlob } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
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 };
209
253
  await this.withEpoch("rw", async () => { await this.outbox.put(record); });
210
254
  await this.checkAccess();
211
255
  this.values.set(record.hash, record);
@@ -244,7 +288,7 @@ export class DurableChatSender {
244
288
  this.emit();
245
289
  }
246
290
  try {
247
- if (record.voiceBlob) {
291
+ if (hasVoice(record)) {
248
292
  record = await this.prepareVoice(record);
249
293
  if (record.confirmed || record.status === "sent")
250
294
  return record.message;
@@ -289,18 +333,19 @@ export class DurableChatSender {
289
333
  releaseUrls() { for (const hash of this.localUrls.keys())
290
334
  this.releaseUrl(hash); }
291
335
  localView(record) {
292
- if (!record.voiceBlob || typeof URL.createObjectURL !== "function")
336
+ if (!hasVoice(record) || typeof URL.createObjectURL !== "function")
293
337
  return record;
294
338
  let cached = this.localViews.get(record);
295
339
  if (cached)
296
340
  return cached;
341
+ const blob = voiceBlob(record);
297
342
  let local = this.localUrls.get(record.hash);
298
- if (!local || local.blob !== record.voiceBlob) {
343
+ if (!local || local.blob !== blob) {
299
344
  this.releaseUrl(record.hash);
300
- local = { blob: record.voiceBlob, url: URL.createObjectURL(record.voiceBlob) };
345
+ local = { blob, url: URL.createObjectURL(blob) };
301
346
  this.localUrls.set(record.hash, local);
302
347
  }
303
- cached = { ...record, message: withVoiceUrl(record.message, local.url) };
348
+ cached = { ...record, voiceBlob: blob, message: withVoiceUrl(record.message, local.url) };
304
349
  this.localViews.set(record, cached);
305
350
  return cached;
306
351
  }
@@ -319,7 +364,7 @@ export class DurableChatSender {
319
364
  const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
320
365
  if (!latest)
321
366
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
322
- if (!latest.voiceBlob || latest.confirmed || latest.status === "sent")
367
+ if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
323
368
  return latest;
324
369
  if (!this.options.uploadVoice)
325
370
  throw new Error("VOICE_UPLOAD_UNAVAILABLE");
@@ -328,7 +373,7 @@ export class DurableChatSender {
328
373
  const timeout = setTimeout(() => controller.abort(), 30_000);
329
374
  let url;
330
375
  try {
331
- url = await this.options.uploadVoice(latest.voiceBlob, controller.signal);
376
+ url = await this.options.uploadVoice(voiceBlob(latest), controller.signal);
332
377
  }
333
378
  finally {
334
379
  clearTimeout(timeout);
@@ -340,10 +385,10 @@ export class DurableChatSender {
340
385
  await this.checkAccess();
341
386
  if (this.closing)
342
387
  throw new Error("CHAT_SENDER_CLOSED");
343
- const { voiceBlob: _blob, ...uploaded } = latest;
388
+ const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
344
389
  const committed = await this.withEpoch("rw", async () => {
345
390
  const current = await this.outbox.get([this.scope, record.hash]);
346
- if (!current || current.confirmed || current.status === "sent" || !current.voiceBlob)
391
+ if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
347
392
  return current;
348
393
  const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
349
394
  await this.outbox.put(next);
@@ -366,7 +411,7 @@ export class DurableChatSender {
366
411
  if (!previous || previous.confirmed || previous.status === "sent")
367
412
  return previous;
368
413
  // A second document may already have durably replaced the Blob with its URL.
369
- if (record.voiceBlob && !previous.voiceBlob)
414
+ if (hasVoice(record) && !hasVoice(previous))
370
415
  return previous;
371
416
  await this.outbox.put(record);
372
417
  return record;
@@ -381,7 +426,7 @@ export class DurableChatSender {
381
426
  await this.ready;
382
427
  await this.withEpoch("rw", async () => {
383
428
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
384
- const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, status: "sent", confirmed: true }));
429
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
385
430
  if (confirmed.length)
386
431
  await this.outbox.bulkPut(confirmed);
387
432
  });
@@ -435,3 +480,5 @@ function withVoiceUrl(message, url) {
435
480
  const body = message.body;
436
481
  return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
437
482
  }
483
+ function hasVoice(record) { return Boolean(record.voiceBlob || record.voiceData); }
484
+ function voiceBlob(record) { return record.voiceBlob ?? new Blob([record.voiceData.bytes], { type: record.voiceData.type }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.43",
3
+ "version": "0.12.0-next.45",
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",