@pmate/chat-sdk 0.12.0-next.57 → 0.12.0-next.59
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/README.md +26 -0
- package/lib/runtime/chatSession.d.ts +3 -1
- package/lib/runtime/chatSession.js +31 -0
- package/lib/runtime/durableSender.d.ts +16 -5
- package/lib/runtime/durableSender.js +161 -68
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +58 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readIndex.d.ts +13 -4
- package/lib/runtime/readIndex.js +216 -138
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -254,3 +254,29 @@ permission change in another team does not discard this team's observations.
|
|
|
254
254
|
Ordinary shutdown waits for accepted local reading writes, while access withdrawal
|
|
255
255
|
rejects late results. Retired asynchronous reading entries reject through promises;
|
|
256
256
|
refresh/subscription teardown remains safe.
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
### Reading storage recovery
|
|
260
|
+
|
|
261
|
+
Large reading snapshots use a persisted journal. The full plan becomes durable
|
|
262
|
+
before canonical rows change; bounded transactions apply it, and the sync cursor
|
|
263
|
+
commits last. A reader recovers an active plan before returning a snapshot. Failed
|
|
264
|
+
staging leaves the previous snapshot authoritative; failure after activation keeps
|
|
265
|
+
the old cursor until recovery completes. Pending reads retain their operation IDs.
|
|
266
|
+
|
|
267
|
+
Writes use at most 128 records per transaction, including journal/lease records,
|
|
268
|
+
and conservatively budget one MiB per batch. Message order is stored per row so a
|
|
269
|
+
source no longer needs a growing array of every message ID. Ordinary observations
|
|
270
|
+
continue to update only positions and pending operations. Locks serialize each
|
|
271
|
+
owner across documents; a fenced IndexedDB lease supports contexts without Web Locks.
|
|
272
|
+
|
|
273
|
+
The private reading database format is now 60. Existing stores are renamed by
|
|
274
|
+
IndexedDB metadata operations, preserving keys and records. Older SDKs cannot
|
|
275
|
+
write through the former namespace and need a page reload to use the new format.
|
|
276
|
+
The message-body cache is separate and unchanged. The public reading state remains
|
|
277
|
+
schema version 1; applications do not select storage formats or recovery steps.
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
Pending voice snapshots contain `voiceAttachment` metadata, without loading audio bytes or creating Blob URLs at startup. For playback or transcription, acquire media through `await chat.getVoiceAttachment(threadId, messageId)`, use the returned `url`, and call `release()` when the consumer stops. The same method returns a remote voice reference for delivered messages. Attachment reads are charged before allocation, and close/revocation releases local URLs. A pending voice remains unread until the player actually starts; acquiring media does not mark it read.
|
|
281
|
+
|
|
282
|
+
The outbox format upgrade renames the old canonical store without copying binary data in the schema transaction, then atomically separates inline payloads in bounded, resumable transactions. An already-open older application must reload; unsupported senders reject the new format. Original wire/hash, failed status and retry semantics are retained.
|
|
@@ -6,7 +6,7 @@ import { type ReadCountsResult } from "./readCounts.js";
|
|
|
6
6
|
import type { OnlineReachEvent } from "./reach.js";
|
|
7
7
|
import { type MessageReadersQuery } from "./messageReaders.js";
|
|
8
8
|
import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
|
|
9
|
-
import { DurableChatSender, type SendMessageInput, type DurableSenderOptions } from "./durableSender.js";
|
|
9
|
+
import { DurableChatSender, type SendMessageInput, type DurableSenderOptions, type VoiceAttachmentLease } from "./durableSender.js";
|
|
10
10
|
import { ReadingClient, type ReadingClientOptions } from "./readingClient.js";
|
|
11
11
|
import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
|
|
12
12
|
import { unreadInConversation } from "./readIndex.js";
|
|
@@ -124,6 +124,8 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
124
124
|
sendMessage(thread: ChatConversation, input: OutgoingContent): Promise<ChatRuntimeMessage>;
|
|
125
125
|
sendMessage(threadId: string, input: SendMessageInput): Promise<ChatRuntimeMessage>;
|
|
126
126
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
127
|
+
/** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
|
|
128
|
+
getVoiceAttachment(threadId: string, messageId: string): Promise<VoiceAttachmentLease>;
|
|
127
129
|
retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
|
|
128
130
|
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): ReturnType<ReadingClient["recordVisible"]>;
|
|
129
131
|
observeMessages(threadId: string | undefined, ids: string[], event: "receipts", clientInstanceId?: string): Promise<void>;
|
|
@@ -380,6 +380,37 @@ export class ChatSession {
|
|
|
380
380
|
}
|
|
381
381
|
return result;
|
|
382
382
|
}
|
|
383
|
+
/** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
|
|
384
|
+
async getVoiceAttachment(threadId, messageId) {
|
|
385
|
+
this.assertOpen();
|
|
386
|
+
const item = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
|
|
387
|
+
if (!item || item.recalled)
|
|
388
|
+
throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
|
|
389
|
+
const local = await this.sender.acquireVoice(messageId, threadId);
|
|
390
|
+
try {
|
|
391
|
+
this.assertOpen();
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
local?.release();
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
const stillVisible = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
|
|
398
|
+
if (!stillVisible || stillVisible.recalled) {
|
|
399
|
+
local?.release();
|
|
400
|
+
throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
|
|
401
|
+
}
|
|
402
|
+
if (local)
|
|
403
|
+
return local;
|
|
404
|
+
const current = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
|
|
405
|
+
const body = current?.message?.body;
|
|
406
|
+
const value = body?.content?.voice?.url;
|
|
407
|
+
if (!current || current.recalled || typeof value !== "string")
|
|
408
|
+
throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
|
|
409
|
+
const url = new URL(value);
|
|
410
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
411
|
+
throw new Error("INVALID_VOICE_ATTACHMENT_URL");
|
|
412
|
+
return { url: value, release: () => { } };
|
|
413
|
+
}
|
|
383
414
|
retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
|
|
384
415
|
observeMessages(threadId, ids, event, clientInstanceId) {
|
|
385
416
|
if (this.closed || this.withdrawn) {
|
|
@@ -15,6 +15,14 @@ export type OutgoingMessage = {
|
|
|
15
15
|
bytes: ArrayBuffer;
|
|
16
16
|
type: string;
|
|
17
17
|
};
|
|
18
|
+
voiceAttachment?: {
|
|
19
|
+
size: number;
|
|
20
|
+
type: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export type VoiceAttachmentLease = {
|
|
24
|
+
url: string;
|
|
25
|
+
release: () => void;
|
|
18
26
|
};
|
|
19
27
|
export type SendMessageInput = {
|
|
20
28
|
to: string;
|
|
@@ -40,9 +48,11 @@ export declare class DurableChatSender {
|
|
|
40
48
|
private options;
|
|
41
49
|
private db;
|
|
42
50
|
private outbox;
|
|
51
|
+
private attachments;
|
|
52
|
+
private schemaReady;
|
|
53
|
+
private attachmentBytes;
|
|
54
|
+
private attachmentLeases;
|
|
43
55
|
private epochs;
|
|
44
|
-
private localUrls;
|
|
45
|
-
private localViews;
|
|
46
56
|
private uploads;
|
|
47
57
|
private reconciliations;
|
|
48
58
|
private firstDelivery;
|
|
@@ -104,9 +114,10 @@ export declare class DurableChatSender {
|
|
|
104
114
|
retry(hash: string): Promise<ChatRuntimeMessage>;
|
|
105
115
|
private transmit;
|
|
106
116
|
private reconcile;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
private
|
|
117
|
+
/** Only explicit playback/transcription acquires a local payload, never snapshots. */
|
|
118
|
+
acquireVoice(hash: string, threadId: string): Promise<VoiceAttachmentLease | undefined>;
|
|
119
|
+
private reserveAttachment;
|
|
120
|
+
private readVoice;
|
|
110
121
|
private prepareVoice;
|
|
111
122
|
private save;
|
|
112
123
|
private queueIO;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { outboxDatabase, prepareOutboxSchema } from "./outboxSchema.js";
|
|
2
3
|
import { blake3 } from "@noble/hashes/blake3.js";
|
|
3
4
|
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
4
5
|
import { withChatTeam } from "./teamScope.js";
|
|
@@ -10,9 +11,11 @@ export class DurableChatSender {
|
|
|
10
11
|
options;
|
|
11
12
|
db;
|
|
12
13
|
outbox;
|
|
14
|
+
attachments;
|
|
15
|
+
schemaReady;
|
|
16
|
+
attachmentBytes = 0;
|
|
17
|
+
attachmentLeases = new Map();
|
|
13
18
|
epochs;
|
|
14
|
-
localUrls = new Map();
|
|
15
|
-
localViews = new WeakMap();
|
|
16
19
|
uploads = new Set();
|
|
17
20
|
reconciliations = new Set();
|
|
18
21
|
firstDelivery = new Set();
|
|
@@ -54,11 +57,11 @@ export class DurableChatSender {
|
|
|
54
57
|
this.options = options;
|
|
55
58
|
this.scope = messageScopeKey(scope);
|
|
56
59
|
this.identity = { ...scope };
|
|
57
|
-
|
|
58
|
-
this.
|
|
59
|
-
this.db
|
|
60
|
-
this.db.
|
|
61
|
-
this.
|
|
60
|
+
const factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
|
|
61
|
+
this.schemaReady = prepareOutboxSchema(factory, name);
|
|
62
|
+
this.db = outboxDatabase(factory, name);
|
|
63
|
+
this.outbox = this.db.table("outgoingMessages");
|
|
64
|
+
this.attachments = this.db.table("outgoingAttachments");
|
|
62
65
|
this.epochs = this.db.table("scopeEpochs");
|
|
63
66
|
this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
|
|
64
67
|
this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
|
|
@@ -79,16 +82,19 @@ export class DurableChatSender {
|
|
|
79
82
|
static async readSelected(scope, threadId, ids, version, options = {}) {
|
|
80
83
|
if (ids.length > 100)
|
|
81
84
|
throw new Error("INVALID_OUTBOX_SELECTION");
|
|
82
|
-
const key = messageScopeKey(scope),
|
|
83
|
-
|
|
84
|
-
db
|
|
85
|
-
db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
85
|
+
const key = messageScopeKey(scope), factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
|
|
86
|
+
await prepareOutboxSchema(factory, name);
|
|
87
|
+
const db = outboxDatabase(factory, name);
|
|
86
88
|
try {
|
|
87
|
-
return await db.transaction("r", db.table("
|
|
89
|
+
return await db.transaction("r", db.table("outgoingMessages"), db.table("scopeEpochs"), async () => {
|
|
88
90
|
if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
|
|
89
91
|
return [];
|
|
90
|
-
const rows =
|
|
91
|
-
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (const id of ids) {
|
|
94
|
+
const row = await db.table("outgoingMessages").get([key, id]);
|
|
95
|
+
rows.push(row ? splitAttachment(row).record : undefined);
|
|
96
|
+
}
|
|
97
|
+
return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [splitAttachment(row).record] : []);
|
|
92
98
|
});
|
|
93
99
|
}
|
|
94
100
|
finally {
|
|
@@ -102,8 +108,9 @@ export class DurableChatSender {
|
|
|
102
108
|
return access;
|
|
103
109
|
}
|
|
104
110
|
async restore() {
|
|
111
|
+
await this.schemaReady;
|
|
105
112
|
const access = await this.readAccess();
|
|
106
|
-
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
113
|
+
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
|
|
107
114
|
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
108
115
|
const version = access?.version ?? current;
|
|
109
116
|
if (version < current)
|
|
@@ -129,12 +136,12 @@ export class DurableChatSender {
|
|
|
129
136
|
let after;
|
|
130
137
|
const restored = new Map();
|
|
131
138
|
while (!this.closed && !this.withdrawn) {
|
|
132
|
-
const batch = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
139
|
+
const batch = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
|
|
133
140
|
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
134
141
|
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
135
142
|
const keys = await (loaded.legacy
|
|
136
143
|
? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
|
|
137
|
-
: this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(
|
|
144
|
+
: this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(64).primaryKeys();
|
|
138
145
|
if (!keys.length)
|
|
139
146
|
return { last: undefined, records: [] };
|
|
140
147
|
const last = keys.at(-1)[1];
|
|
@@ -143,11 +150,36 @@ export class DurableChatSender {
|
|
|
143
150
|
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() : [];
|
|
144
151
|
const confirmedHashes = new Set(confirmed.map(key => key[1]));
|
|
145
152
|
const needed = keys.filter(key => !confirmedHashes.has(key[1]));
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await this.outbox.
|
|
150
|
-
|
|
153
|
+
const records = [], writes = [], payloads = [];
|
|
154
|
+
let bytes = 0, processed = last;
|
|
155
|
+
for (const key of needed) {
|
|
156
|
+
const row = await this.outbox.get(key);
|
|
157
|
+
if (!row)
|
|
158
|
+
continue;
|
|
159
|
+
const size = row.voiceData?.bytes.byteLength ?? row.voiceBlob?.size ?? 0;
|
|
160
|
+
if (records.length && bytes + 2 * size > 16 * 1024 * 1024) {
|
|
161
|
+
processed = records.at(-1).hash;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (!row.confirmed && (row.accessVersion ?? 0) === this.version) {
|
|
165
|
+
const split = splitAttachment(row);
|
|
166
|
+
records.push(split.record);
|
|
167
|
+
bytes += 2 * size;
|
|
168
|
+
if (split.attachment) {
|
|
169
|
+
payloads.push(split.attachment);
|
|
170
|
+
writes.push(split.record);
|
|
171
|
+
}
|
|
172
|
+
else if (row.pendingState !== (row.confirmed ? 0 : 1))
|
|
173
|
+
writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
|
|
174
|
+
}
|
|
175
|
+
else if (row.pendingState !== (row.confirmed ? 0 : 1))
|
|
176
|
+
writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
|
|
177
|
+
}
|
|
178
|
+
if (payloads.length)
|
|
179
|
+
await this.attachments.bulkPut(payloads);
|
|
180
|
+
if (writes.length)
|
|
181
|
+
await this.outbox.bulkPut(writes);
|
|
182
|
+
return { last: processed, records };
|
|
151
183
|
});
|
|
152
184
|
for (const row of batch.records)
|
|
153
185
|
restored.set(row.hash, row);
|
|
@@ -181,7 +213,7 @@ export class DurableChatSender {
|
|
|
181
213
|
async withEpoch(mode, operation) {
|
|
182
214
|
await this.checkAccess();
|
|
183
215
|
try {
|
|
184
|
-
return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
|
|
216
|
+
return await this.db.transaction(mode, this.outbox, this.epochs, this.attachments, async () => {
|
|
185
217
|
if (this.closed || this.withdrawn)
|
|
186
218
|
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
187
219
|
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
@@ -195,7 +227,10 @@ export class DurableChatSender {
|
|
|
195
227
|
throw error;
|
|
196
228
|
}
|
|
197
229
|
}
|
|
198
|
-
deleteRows() {
|
|
230
|
+
async deleteRows() {
|
|
231
|
+
await this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
232
|
+
await this.attachments.where("scope").equals(this.scope).delete();
|
|
233
|
+
}
|
|
199
234
|
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
200
235
|
withdraw() {
|
|
201
236
|
this.withdrawn = true;
|
|
@@ -205,7 +240,7 @@ export class DurableChatSender {
|
|
|
205
240
|
this.uploads.forEach(controller => controller.abort());
|
|
206
241
|
this.reconciliations.forEach(controller => controller.abort());
|
|
207
242
|
this.firstDelivery.clear();
|
|
208
|
-
this.
|
|
243
|
+
this.attachmentLeases.forEach(release => release());
|
|
209
244
|
this.values.clear();
|
|
210
245
|
this.emit();
|
|
211
246
|
}
|
|
@@ -218,7 +253,7 @@ export class DurableChatSender {
|
|
|
218
253
|
return this.revoking = (async () => {
|
|
219
254
|
await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
220
255
|
throw error; });
|
|
221
|
-
const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
256
|
+
const version = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, async () => {
|
|
222
257
|
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
223
258
|
const target = targetVersion ?? this.version + 1;
|
|
224
259
|
if (!Number.isSafeInteger(target) || target < 0)
|
|
@@ -239,7 +274,7 @@ export class DurableChatSender {
|
|
|
239
274
|
return this.empty;
|
|
240
275
|
let snapshot = this.snapshots.get(threadId);
|
|
241
276
|
if (!snapshot) {
|
|
242
|
-
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId)
|
|
277
|
+
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
|
|
243
278
|
this.snapshots.set(threadId, snapshot);
|
|
244
279
|
}
|
|
245
280
|
return snapshot;
|
|
@@ -313,13 +348,14 @@ export class DurableChatSender {
|
|
|
313
348
|
}
|
|
314
349
|
drainAcceptances() {
|
|
315
350
|
const task = (async () => {
|
|
316
|
-
let bytes = 0, count = 0;
|
|
351
|
+
let bytes = 0, count = 0, operations = 0;
|
|
317
352
|
for (const item of this.acceptanceQueue) {
|
|
318
353
|
const size = item.bytes;
|
|
319
|
-
if (
|
|
354
|
+
if (operations + (hasVoice(item.record) ? 2 : 1) > 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
|
|
320
355
|
break;
|
|
321
356
|
bytes += size;
|
|
322
357
|
count++;
|
|
358
|
+
operations += hasVoice(item.record) ? 2 : 1;
|
|
323
359
|
}
|
|
324
360
|
const batch = this.acceptanceQueue.splice(0, count);
|
|
325
361
|
try {
|
|
@@ -342,9 +378,13 @@ export class DurableChatSender {
|
|
|
342
378
|
known.set(record.hash, record);
|
|
343
379
|
}
|
|
344
380
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
381
|
+
const split = writes.map(splitAttachment), payloads = split.flatMap(item => item.attachment ? [item.attachment] : []);
|
|
382
|
+
if (payloads.length)
|
|
383
|
+
await this.attachments.bulkPut(payloads);
|
|
384
|
+
if (split.length)
|
|
385
|
+
await this.outbox.bulkPut(split.map(item => item.record));
|
|
386
|
+
const metadata = new Map(split.map(item => [item.record.hash, item.record]));
|
|
387
|
+
return { writes: split.map(item => item.record), records: records.map(record => metadata.get(record.hash) ?? record) };
|
|
348
388
|
});
|
|
349
389
|
await this.checkAccess();
|
|
350
390
|
for (const record of result.writes)
|
|
@@ -475,28 +515,53 @@ export class DurableChatSender {
|
|
|
475
515
|
this.reconciliations.delete(controller);
|
|
476
516
|
}
|
|
477
517
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
this.
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
this.
|
|
494
|
-
|
|
495
|
-
|
|
518
|
+
/** Only explicit playback/transcription acquires a local payload, never snapshots. */
|
|
519
|
+
async acquireVoice(hash, threadId) {
|
|
520
|
+
await this.ready;
|
|
521
|
+
await this.checkAccess();
|
|
522
|
+
const record = await this.queueIO(hash);
|
|
523
|
+
if (!record || record.threadId !== threadId || !hasVoice(record))
|
|
524
|
+
return undefined;
|
|
525
|
+
const releaseBudget = this.reserveAttachment(record), key = Symbol(hash);
|
|
526
|
+
let url, released = false;
|
|
527
|
+
const release = () => { if (released)
|
|
528
|
+
return; released = true; if (url)
|
|
529
|
+
URL.revokeObjectURL(url); releaseBudget(); this.attachmentLeases.delete(key); };
|
|
530
|
+
this.attachmentLeases.set(key, release);
|
|
531
|
+
try {
|
|
532
|
+
const blob = await this.readVoice(record);
|
|
533
|
+
await this.checkAccess();
|
|
534
|
+
if (released || this.closing)
|
|
535
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
536
|
+
url = URL.createObjectURL(blob);
|
|
537
|
+
return { url, release };
|
|
496
538
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
539
|
+
catch (error) {
|
|
540
|
+
release();
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
reserveAttachment(record) {
|
|
545
|
+
const size = record.voiceAttachment?.size ?? record.voiceData?.bytes.byteLength ?? record.voiceBlob?.size ?? 0;
|
|
546
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > 16 * 1024 * 1024)
|
|
547
|
+
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
548
|
+
// Charge both the native read buffer and Blob construction, including active leases.
|
|
549
|
+
const bytes = 2 * size;
|
|
550
|
+
if (this.attachmentBytes + bytes > 64 * 1024 * 1024)
|
|
551
|
+
throw new Error("VOICE_ATTACHMENT_BACKPRESSURE");
|
|
552
|
+
this.attachmentBytes += bytes;
|
|
553
|
+
let released = false;
|
|
554
|
+
return () => { if (!released) {
|
|
555
|
+
released = true;
|
|
556
|
+
this.attachmentBytes -= bytes;
|
|
557
|
+
} };
|
|
558
|
+
}
|
|
559
|
+
async readVoice(record) {
|
|
560
|
+
const row = await this.withEpoch("r", () => this.attachments.get([this.scope, record.hash]));
|
|
561
|
+
await this.checkAccess();
|
|
562
|
+
if (!row || row.accessVersion !== this.version || row.size !== record.voiceAttachment?.size || row.type !== record.voiceAttachment.type || (row.bytes?.byteLength ?? row.blob?.size) !== row.size)
|
|
563
|
+
throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
|
|
564
|
+
return row.blob ?? new Blob([row.bytes], { type: row.type });
|
|
500
565
|
}
|
|
501
566
|
async prepareVoice(record) {
|
|
502
567
|
if (this.uploadWaiting >= 32)
|
|
@@ -517,14 +582,19 @@ export class DurableChatSender {
|
|
|
517
582
|
return latest;
|
|
518
583
|
if (!this.options.uploadVoice)
|
|
519
584
|
throw new Error("VOICE_UPLOAD_UNAVAILABLE");
|
|
585
|
+
const release = this.reserveAttachment(latest);
|
|
520
586
|
const controller = new AbortController();
|
|
521
587
|
this.uploads.add(controller);
|
|
522
588
|
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
523
589
|
let url;
|
|
524
590
|
try {
|
|
525
|
-
|
|
591
|
+
const blob = await this.readVoice(latest);
|
|
592
|
+
if (controller.signal.aborted || this.closing)
|
|
593
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
594
|
+
url = await this.options.uploadVoice(blob, controller.signal);
|
|
526
595
|
}
|
|
527
596
|
finally {
|
|
597
|
+
release();
|
|
528
598
|
clearTimeout(timeout);
|
|
529
599
|
this.uploads.delete(controller);
|
|
530
600
|
}
|
|
@@ -534,7 +604,7 @@ export class DurableChatSender {
|
|
|
534
604
|
await this.checkAccess();
|
|
535
605
|
if (this.closing)
|
|
536
606
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
537
|
-
const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
|
|
607
|
+
const { voiceBlob: _blob, voiceData: _data, voiceAttachment: _attachment, ...uploaded } = latest;
|
|
538
608
|
const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
|
|
539
609
|
if (!committed)
|
|
540
610
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
@@ -575,12 +645,13 @@ export class DurableChatSender {
|
|
|
575
645
|
}
|
|
576
646
|
drainIO() {
|
|
577
647
|
const task = (async () => {
|
|
578
|
-
let count = 0, bytes = 0;
|
|
648
|
+
let count = 0, bytes = 0, operations = 0;
|
|
579
649
|
for (const item of this.ioQueue) {
|
|
580
|
-
if (
|
|
650
|
+
if (operations + (item.uploaded ? 2 : 1) > 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
|
|
581
651
|
break;
|
|
582
652
|
count++;
|
|
583
653
|
bytes += item.bytes;
|
|
654
|
+
operations += item.uploaded ? 2 : 1;
|
|
584
655
|
}
|
|
585
656
|
const batch = this.ioQueue.splice(0, count);
|
|
586
657
|
try {
|
|
@@ -598,8 +669,12 @@ export class DurableChatSender {
|
|
|
598
669
|
}
|
|
599
670
|
result.push(current.get(item.hash));
|
|
600
671
|
}
|
|
601
|
-
if (writes.size)
|
|
672
|
+
if (writes.size) {
|
|
602
673
|
await this.outbox.bulkPut([...writes.values()]);
|
|
674
|
+
const released = [...writes.values()].filter(record => !hasVoice(record) && hasVoice(rows[hashes.indexOf(record.hash)]));
|
|
675
|
+
if (released.length)
|
|
676
|
+
await this.attachments.bulkDelete(released.map(record => [this.scope, record.hash]));
|
|
677
|
+
}
|
|
603
678
|
return result;
|
|
604
679
|
});
|
|
605
680
|
await this.checkAccess();
|
|
@@ -610,7 +685,6 @@ export class DurableChatSender {
|
|
|
610
685
|
const record = records[index], old = this.values.get(item.hash);
|
|
611
686
|
if (!record || record.confirmed) {
|
|
612
687
|
changed = this.values.delete(item.hash) || changed;
|
|
613
|
-
this.releaseUrl(item.hash);
|
|
614
688
|
}
|
|
615
689
|
else if (!old || old.status !== record.status || old.accessVersion !== record.accessVersion || JSON.stringify(old.message) !== JSON.stringify(record.message) || hasVoice(old) !== hasVoice(record)) {
|
|
616
690
|
this.values.set(item.hash, record);
|
|
@@ -694,21 +768,33 @@ export class DurableChatSender {
|
|
|
694
768
|
await this.ready;
|
|
695
769
|
let changed = false;
|
|
696
770
|
try {
|
|
697
|
-
for (let offset = 0; offset < hashes.length;
|
|
698
|
-
|
|
771
|
+
for (let offset = 0; offset < hashes.length;) {
|
|
772
|
+
let end = offset, operations = 0;
|
|
773
|
+
while (end < hashes.length) {
|
|
774
|
+
const known = this.values.get(hashes[end]), cost = known && !hasVoice(known) ? 1 : 2;
|
|
775
|
+
if (operations + cost > 128)
|
|
776
|
+
break;
|
|
777
|
+
operations += cost;
|
|
778
|
+
end++;
|
|
779
|
+
}
|
|
780
|
+
const batch = hashes.slice(offset, end);
|
|
699
781
|
await this.withEpoch("rw", async () => {
|
|
700
782
|
const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
|
|
701
|
-
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
|
|
702
|
-
if (confirmed.length)
|
|
783
|
+
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, voiceAttachment: undefined, status: "sent", confirmed: true }));
|
|
784
|
+
if (confirmed.length) {
|
|
703
785
|
await this.outbox.bulkPut(confirmed);
|
|
786
|
+
const voices = previous.filter((record) => Boolean(record && hasVoice(record)));
|
|
787
|
+
if (voices.length)
|
|
788
|
+
await this.attachments.bulkDelete(voices.map(record => [this.scope, record.hash]));
|
|
789
|
+
}
|
|
704
790
|
});
|
|
705
791
|
// Retire only committed rows. A later failed transaction leaves the
|
|
706
792
|
// remaining sends retryable, including after a browser restart.
|
|
707
793
|
for (const hash of batch) {
|
|
708
794
|
changed = this.values.delete(hash) || changed;
|
|
709
|
-
this.releaseUrl(hash);
|
|
710
795
|
}
|
|
711
|
-
|
|
796
|
+
offset = end;
|
|
797
|
+
if (offset < hashes.length)
|
|
712
798
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
713
799
|
}
|
|
714
800
|
}
|
|
@@ -738,6 +824,7 @@ export class DurableChatSender {
|
|
|
738
824
|
}
|
|
739
825
|
async close() {
|
|
740
826
|
this.closing = true;
|
|
827
|
+
this.attachmentLeases.forEach(release => release());
|
|
741
828
|
this.uploads.forEach(controller => controller.abort());
|
|
742
829
|
this.reconciliations.forEach(controller => controller.abort());
|
|
743
830
|
this.offConnection();
|
|
@@ -753,7 +840,7 @@ export class DurableChatSender {
|
|
|
753
840
|
this.closed = true;
|
|
754
841
|
this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
|
|
755
842
|
this.firstDelivery.clear();
|
|
756
|
-
this.
|
|
843
|
+
this.attachmentLeases.forEach(release => release());
|
|
757
844
|
this.values.clear();
|
|
758
845
|
this.snapshots.clear();
|
|
759
846
|
this.db.close();
|
|
@@ -776,5 +863,11 @@ function withVoiceUrl(message, url) {
|
|
|
776
863
|
const body = message.body;
|
|
777
864
|
return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
|
|
778
865
|
}
|
|
779
|
-
function hasVoice(record) { return Boolean(record.voiceBlob || record.voiceData); }
|
|
780
|
-
function
|
|
866
|
+
function hasVoice(record) { return Boolean(record && (record.voiceBlob || record.voiceData || record.voiceAttachment)); }
|
|
867
|
+
function splitAttachment(record) {
|
|
868
|
+
const { voiceBlob, voiceData, ...metadata } = record;
|
|
869
|
+
if (!voiceBlob && !voiceData)
|
|
870
|
+
return { record: metadata };
|
|
871
|
+
const size = voiceData?.bytes.byteLength ?? voiceBlob.size, type = voiceData?.type ?? voiceBlob.type;
|
|
872
|
+
return { record: { ...metadata, pendingState: record.confirmed ? 0 : 1, voiceAttachment: { size, type } }, attachment: { scope: record.scope, hash: record.hash, accessVersion: record.accessVersion ?? 0, size, type, ...(voiceData ? { bytes: voiceData.bytes } : { blob: voiceBlob }) } };
|
|
873
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
/** Rename canonical rows without copying attachments in a versionchange transaction.
|
|
3
|
+
* An old sender must fail closed, rather than send a metadata-only voice wire. */
|
|
4
|
+
export declare function prepareOutboxSchema(factory: IDBFactory, name: string): Promise<void>;
|
|
5
|
+
export declare function outboxDatabase(factory: IDBFactory, name: string): Dexie;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
const messageSchema = "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]";
|
|
3
|
+
/** Rename canonical rows without copying attachments in a versionchange transaction.
|
|
4
|
+
* An old sender must fail closed, rather than send a metadata-only voice wire. */
|
|
5
|
+
export async function prepareOutboxSchema(factory, name) {
|
|
6
|
+
const existing = await new Promise((resolve, reject) => {
|
|
7
|
+
const request = factory.open(name);
|
|
8
|
+
let fresh = false;
|
|
9
|
+
request.onupgradeneeded = event => { if (event.oldVersion === 0) {
|
|
10
|
+
fresh = true;
|
|
11
|
+
request.transaction.abort();
|
|
12
|
+
} };
|
|
13
|
+
request.onerror = event => { event.preventDefault(); if (fresh)
|
|
14
|
+
resolve(undefined);
|
|
15
|
+
else
|
|
16
|
+
reject(request.error); };
|
|
17
|
+
request.onsuccess = () => resolve(request.result);
|
|
18
|
+
});
|
|
19
|
+
if (!existing)
|
|
20
|
+
return;
|
|
21
|
+
const version = existing.version, prepared = existing.objectStoreNames.contains("outgoingMessages") && existing.objectStoreNames.contains("outgoingAttachments");
|
|
22
|
+
existing.close();
|
|
23
|
+
if (version >= 40 && prepared)
|
|
24
|
+
return;
|
|
25
|
+
await new Promise((resolve, reject) => {
|
|
26
|
+
const request = factory.open(name, Math.max(40, version + 1));
|
|
27
|
+
request.onerror = () => reject(request.error);
|
|
28
|
+
request.onupgradeneeded = () => {
|
|
29
|
+
const db = request.result, tx = request.transaction;
|
|
30
|
+
if (db.objectStoreNames.contains("outbox")) {
|
|
31
|
+
if (db.objectStoreNames.contains("outgoingMessages")) {
|
|
32
|
+
tx.abort();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
tx.objectStore("outbox").name = "outgoingMessages";
|
|
36
|
+
}
|
|
37
|
+
db.createObjectStore("outbox", { keyPath: "unsupportedOutboxFormat" });
|
|
38
|
+
const define = (name, keyPath, indexes) => {
|
|
39
|
+
const store = db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath });
|
|
40
|
+
for (const [index, path] of Object.entries(indexes))
|
|
41
|
+
if (!store.indexNames.contains(index))
|
|
42
|
+
store.createIndex(index, path);
|
|
43
|
+
};
|
|
44
|
+
define("outgoingMessages", ["scope", "hash"], { scope: "scope", "[scope+threadId]": ["scope", "threadId"], "[scope+pendingState+hash]": ["scope", "pendingState", "hash"] });
|
|
45
|
+
define("outgoingAttachments", ["scope", "hash"], { scope: "scope" });
|
|
46
|
+
define("scopeEpochs", "scope", {});
|
|
47
|
+
};
|
|
48
|
+
request.onsuccess = () => { request.result.close(); resolve(); };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export function outboxDatabase(factory, name) {
|
|
52
|
+
const db = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
|
|
53
|
+
db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
54
|
+
db.version(2).stores({ scopeEpochs: "scope" });
|
|
55
|
+
db.version(3).stores({ outbox: messageSchema });
|
|
56
|
+
db.version(4).stores({ outbox: "unsupportedOutboxFormat", outgoingMessages: messageSchema, outgoingAttachments: "[scope+hash],scope" });
|
|
57
|
+
return db;
|
|
58
|
+
}
|