@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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 !==
|
|
343
|
+
if (!local || local.blob !== blob) {
|
|
299
344
|
this.releaseUrl(record.hash);
|
|
300
|
-
local = { blob
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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