@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.70
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 +152 -81
- package/lib/advanced.d.ts +10 -0
- package/lib/advanced.js +10 -0
- package/lib/chat.d.ts +5 -0
- package/lib/chat.js +11 -0
- package/lib/chatContract.d.ts +148 -0
- package/lib/chatContract.js +1 -0
- package/lib/chatError.d.ts +11 -0
- package/lib/chatError.js +13 -0
- package/lib/chatTypes.d.ts +121 -0
- package/lib/chatTypes.js +1 -0
- package/lib/credentials.d.ts +26 -0
- package/lib/credentials.js +110 -0
- package/lib/fileMetadata.d.ts +9 -0
- package/lib/fileMetadata.js +18 -0
- package/lib/framework.d.ts +11 -0
- package/lib/framework.js +12 -0
- package/lib/history.d.ts +4 -0
- package/lib/history.js +8 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +9 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/internal/chatEngine.d.ts +131 -0
- package/lib/internal/chatEngine.js +280 -0
- package/lib/internal/chatRegistry.d.ts +4 -0
- package/lib/internal/chatRegistry.js +4 -0
- package/lib/internal/createPublicChat.d.ts +4 -0
- package/lib/internal/createPublicChat.js +52 -0
- package/lib/messageView.d.ts +6 -0
- package/lib/messageView.js +103 -0
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +2 -0
- package/lib/runtime/chatClient.js +8 -3
- package/lib/runtime/chatError.d.ts +3 -0
- package/lib/runtime/chatError.js +15 -0
- package/lib/runtime/chatSession.d.ts +127 -13
- package/lib/runtime/chatSession.js +727 -40
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationDirectory.d.ts +37 -0
- package/lib/runtime/conversationDirectory.js +116 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +11 -7
- package/lib/runtime/createChat.js +46 -23
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +76 -0
- package/lib/runtime/groupClient.js +325 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/messageClient.d.ts +35 -8
- package/lib/runtime/messageClient.js +364 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +44 -0
- package/lib/runtime/messageLaunch.js +137 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +67 -3
- package/lib/runtime/messageStore.d.ts +40 -2
- package/lib/runtime/messageStore.js +227 -11
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +59 -0
- package/lib/runtime/outgoingContent.d.ts +89 -0
- package/lib/runtime/outgoingContent.js +113 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +97 -9
- package/lib/runtime/readIndex.d.ts +68 -7
- package/lib/runtime/readIndex.js +460 -22
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +26 -9
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/responseCache.d.ts +59 -0
- package/lib/runtime/responseCache.js +232 -0
- package/lib/runtime/socialReadClient.d.ts +4 -2
- package/lib/runtime/socialReadClient.js +12 -8
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- package/package.json +27 -2
|
@@ -1,12 +1,47 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { validMessageIdentifier } from "./outgoingContent.js";
|
|
3
|
+
import { outboxDatabase, prepareOutboxSchema } from "./outboxSchema.js";
|
|
4
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
5
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
2
6
|
import { withChatTeam } from "./teamScope.js";
|
|
3
7
|
import { messageScopeKey } from "./messageStore.js";
|
|
4
8
|
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
5
9
|
export class DurableChatSender {
|
|
6
10
|
client;
|
|
7
11
|
onError;
|
|
12
|
+
options;
|
|
8
13
|
db;
|
|
9
14
|
outbox;
|
|
15
|
+
controlRevisions;
|
|
16
|
+
attachments;
|
|
17
|
+
schemaReady;
|
|
18
|
+
attachmentBytes = 0;
|
|
19
|
+
attachmentLeases = new Map();
|
|
20
|
+
epochs;
|
|
21
|
+
uploads = new Set();
|
|
22
|
+
reconciliations = new Set();
|
|
23
|
+
firstDelivery = new Set();
|
|
24
|
+
ioQueue = [];
|
|
25
|
+
ioTimer;
|
|
26
|
+
ioRunning;
|
|
27
|
+
ioCount = 0;
|
|
28
|
+
ioBytes = 0;
|
|
29
|
+
acceptanceQueue = [];
|
|
30
|
+
acceptanceTimer;
|
|
31
|
+
acceptanceRunning;
|
|
32
|
+
acceptanceCount = 0;
|
|
33
|
+
acceptanceBytes = 0;
|
|
34
|
+
uploadTail = Promise.resolve();
|
|
35
|
+
uploadWaiting = 0;
|
|
36
|
+
confirmationQueue = [];
|
|
37
|
+
confirmationTimer;
|
|
38
|
+
confirmationRunning;
|
|
39
|
+
confirmationSize = 0;
|
|
40
|
+
version = 0;
|
|
41
|
+
withdrawn = false;
|
|
42
|
+
revoking;
|
|
43
|
+
channel;
|
|
44
|
+
accepted = new Set();
|
|
10
45
|
scope;
|
|
11
46
|
identity;
|
|
12
47
|
values = new Map();
|
|
@@ -14,30 +49,233 @@ export class DurableChatSender {
|
|
|
14
49
|
listeners = new Set();
|
|
15
50
|
running = new Map();
|
|
16
51
|
closed = false;
|
|
52
|
+
closing = false;
|
|
53
|
+
empty = [];
|
|
17
54
|
offConnection;
|
|
18
55
|
ready;
|
|
19
56
|
constructor(client, scope, onError, options = {}) {
|
|
20
57
|
this.client = client;
|
|
21
58
|
this.onError = onError;
|
|
59
|
+
this.options = options;
|
|
22
60
|
this.scope = messageScopeKey(scope);
|
|
23
61
|
this.identity = { ...scope };
|
|
24
|
-
|
|
25
|
-
this.
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
62
|
+
const factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
|
|
63
|
+
this.schemaReady = prepareOutboxSchema(factory, name);
|
|
64
|
+
this.db = outboxDatabase(factory, name);
|
|
65
|
+
this.outbox = this.db.table("outgoingMessages");
|
|
66
|
+
this.attachments = this.db.table("outgoingAttachments");
|
|
67
|
+
this.controlRevisions = this.db.table("outgoingControls");
|
|
68
|
+
this.epochs = this.db.table("scopeEpochs");
|
|
69
|
+
this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
|
|
70
|
+
this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
|
|
71
|
+
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
72
|
+
this.ready = this.restore();
|
|
73
|
+
void this.ready.catch(() => { });
|
|
74
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
75
|
+
this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
|
|
76
|
+
this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
|
|
77
|
+
this.withdraw(); };
|
|
78
|
+
}
|
|
33
79
|
this.offConnection = client.watchConnection(state => {
|
|
34
80
|
if (state === "connected")
|
|
35
81
|
void this.resume().catch(onError);
|
|
36
82
|
});
|
|
37
83
|
}
|
|
84
|
+
/** Read only the selected durable rows, without restoring or delivering an outbox. */
|
|
85
|
+
static async readSelected(scope, threadId, ids, version, options = {}) {
|
|
86
|
+
if (ids.length > 100)
|
|
87
|
+
throw new Error("INVALID_OUTBOX_SELECTION");
|
|
88
|
+
const key = messageScopeKey(scope), factory = options.indexedDB ?? globalThis.indexedDB, name = options.name ?? "pmate-chat-outbox";
|
|
89
|
+
await prepareOutboxSchema(factory, name);
|
|
90
|
+
const db = outboxDatabase(factory, name);
|
|
91
|
+
try {
|
|
92
|
+
return await db.transaction("r", db.table("outgoingMessages"), db.table("scopeEpochs"), async () => {
|
|
93
|
+
if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
|
|
94
|
+
return [];
|
|
95
|
+
const rows = [];
|
|
96
|
+
for (const id of ids) {
|
|
97
|
+
const row = await db.table("outgoingMessages").get([key, id]);
|
|
98
|
+
rows.push(row ? splitAttachment(row).record : undefined);
|
|
99
|
+
}
|
|
100
|
+
return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [splitAttachment(row).record] : []);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
db.close();
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async readAccess() {
|
|
108
|
+
const access = await this.options.access?.();
|
|
109
|
+
if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
|
|
110
|
+
throw new Error("INVALID_OUTBOX_ACCESS");
|
|
111
|
+
return access;
|
|
112
|
+
}
|
|
113
|
+
async restore() {
|
|
114
|
+
await this.schemaReady;
|
|
115
|
+
const access = await this.readAccess();
|
|
116
|
+
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
117
|
+
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
118
|
+
const version = access?.version ?? current;
|
|
119
|
+
if (version < current)
|
|
120
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
121
|
+
if (version > current) {
|
|
122
|
+
await this.epochs.put({ scope: this.scope, version });
|
|
123
|
+
await this.deleteRows();
|
|
124
|
+
}
|
|
125
|
+
const total = await this.outbox.where("scope").equals(this.scope).count();
|
|
126
|
+
const indexed = await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, Dexie.minKey], [this.scope, 1, Dexie.maxKey], true, true).count();
|
|
127
|
+
return { version, advanced: version > current, legacy: total !== indexed };
|
|
128
|
+
});
|
|
129
|
+
this.version = loaded.version;
|
|
130
|
+
if (loaded.advanced)
|
|
131
|
+
this.channel?.postMessage({ version: this.version });
|
|
132
|
+
if (this.closed || this.withdrawn)
|
|
133
|
+
return;
|
|
134
|
+
const current = await this.readAccess();
|
|
135
|
+
if (current && (!current.allowed || current.version !== this.version))
|
|
136
|
+
return;
|
|
137
|
+
if (access?.allowed === false)
|
|
138
|
+
return;
|
|
139
|
+
let after;
|
|
140
|
+
const restored = new Map();
|
|
141
|
+
while (!this.closed && !this.withdrawn) {
|
|
142
|
+
const batch = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
143
|
+
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
144
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
145
|
+
const keys = await (loaded.legacy
|
|
146
|
+
? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
|
|
147
|
+
: this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(64).primaryKeys();
|
|
148
|
+
if (!keys.length)
|
|
149
|
+
return { last: undefined, records: [] };
|
|
150
|
+
const last = keys.at(-1)[1];
|
|
151
|
+
// Key-only metadata covers confirmed history without reading wire bodies.
|
|
152
|
+
// Missing metadata identifies rows written by an older SDK after upgrade.
|
|
153
|
+
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() : [];
|
|
154
|
+
const confirmedHashes = new Set(confirmed.map(key => key[1]));
|
|
155
|
+
const needed = keys.filter(key => !confirmedHashes.has(key[1]));
|
|
156
|
+
const records = [], writes = [], payloads = [];
|
|
157
|
+
let bytes = 0, processed = last;
|
|
158
|
+
for (const key of needed) {
|
|
159
|
+
const row = await this.outbox.get(key);
|
|
160
|
+
if (!row)
|
|
161
|
+
continue;
|
|
162
|
+
const size = row.voiceData?.bytes.byteLength ?? row.voiceBlob?.size ?? 0;
|
|
163
|
+
if (records.length && bytes + 2 * size > 16 * 1024 * 1024) {
|
|
164
|
+
processed = records.at(-1).hash;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
if (!row.confirmed && (row.accessVersion ?? 0) === this.version) {
|
|
168
|
+
const split = splitAttachment(row);
|
|
169
|
+
records.push(split.record);
|
|
170
|
+
bytes += 2 * size;
|
|
171
|
+
if (split.attachment) {
|
|
172
|
+
payloads.push(split.attachment);
|
|
173
|
+
writes.push(split.record);
|
|
174
|
+
}
|
|
175
|
+
else if (row.pendingState !== (row.confirmed ? 0 : 1))
|
|
176
|
+
writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
|
|
177
|
+
}
|
|
178
|
+
else if (row.pendingState !== (row.confirmed ? 0 : 1))
|
|
179
|
+
writes.push({ ...row, pendingState: row.confirmed ? 0 : 1 });
|
|
180
|
+
}
|
|
181
|
+
if (payloads.length)
|
|
182
|
+
await this.attachments.bulkPut(payloads);
|
|
183
|
+
if (writes.length)
|
|
184
|
+
await this.outbox.bulkPut(writes);
|
|
185
|
+
return { last: processed, records };
|
|
186
|
+
});
|
|
187
|
+
for (const row of batch.records)
|
|
188
|
+
restored.set(row.hash, row);
|
|
189
|
+
if (batch.last === undefined)
|
|
190
|
+
break;
|
|
191
|
+
after = batch.last;
|
|
192
|
+
// Leave the database transaction before yielding to input/rendering work.
|
|
193
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
194
|
+
}
|
|
195
|
+
if (this.closed || this.withdrawn)
|
|
196
|
+
return;
|
|
197
|
+
const latest = await this.readAccess();
|
|
198
|
+
if (latest && (!latest.allowed || latest.version !== this.version))
|
|
199
|
+
return;
|
|
200
|
+
this.values = restored;
|
|
201
|
+
this.emit();
|
|
202
|
+
}
|
|
203
|
+
async checkAccess() {
|
|
204
|
+
if (this.closed)
|
|
205
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
206
|
+
if (this.withdrawn)
|
|
207
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
208
|
+
const access = await this.readAccess();
|
|
209
|
+
if (access && access.version !== this.version) {
|
|
210
|
+
this.withdraw();
|
|
211
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
212
|
+
}
|
|
213
|
+
if (access && !access.allowed)
|
|
214
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
215
|
+
}
|
|
216
|
+
async withEpoch(mode, operation) {
|
|
217
|
+
await this.checkAccess();
|
|
218
|
+
try {
|
|
219
|
+
return await this.db.transaction(mode, this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
220
|
+
if (this.closed || this.withdrawn)
|
|
221
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
222
|
+
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
223
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
224
|
+
return operation();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
229
|
+
this.withdraw();
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
async deleteRows() {
|
|
234
|
+
await this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
235
|
+
await this.attachments.where("scope").equals(this.scope).delete();
|
|
236
|
+
await this.controlRevisions.where("scope").equals(this.scope).delete();
|
|
237
|
+
}
|
|
238
|
+
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
239
|
+
withdraw() {
|
|
240
|
+
this.withdrawn = true;
|
|
241
|
+
this.failIO(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
242
|
+
this.failAcceptances(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
243
|
+
this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
244
|
+
this.uploads.forEach(controller => controller.abort());
|
|
245
|
+
this.reconciliations.forEach(controller => controller.abort());
|
|
246
|
+
this.firstDelivery.clear();
|
|
247
|
+
this.attachmentLeases.forEach(release => release());
|
|
248
|
+
this.values.clear();
|
|
249
|
+
this.emit();
|
|
250
|
+
}
|
|
251
|
+
revoke(targetVersion) {
|
|
252
|
+
if (this.closed || this.closing)
|
|
253
|
+
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
254
|
+
if (this.revoking)
|
|
255
|
+
return this.revoking;
|
|
256
|
+
this.withdraw();
|
|
257
|
+
return this.revoking = (async () => {
|
|
258
|
+
await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
259
|
+
throw error; });
|
|
260
|
+
const version = await this.db.transaction("rw", this.outbox, this.epochs, this.attachments, this.controlRevisions, async () => {
|
|
261
|
+
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
262
|
+
const target = targetVersion ?? this.version + 1;
|
|
263
|
+
if (!Number.isSafeInteger(target) || target < 0)
|
|
264
|
+
throw new Error("INVALID_OUTBOX_EPOCH");
|
|
265
|
+
if (current >= target)
|
|
266
|
+
return current;
|
|
267
|
+
await this.epochs.put({ scope: this.scope, version: target });
|
|
268
|
+
await this.deleteRows();
|
|
269
|
+
return target;
|
|
270
|
+
});
|
|
271
|
+
this.channel?.postMessage({ version });
|
|
272
|
+
})();
|
|
273
|
+
}
|
|
38
274
|
initialize() { return this.ready; }
|
|
39
275
|
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
40
276
|
snapshot(threadId) {
|
|
277
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
278
|
+
return this.empty;
|
|
41
279
|
let snapshot = this.snapshots.get(threadId);
|
|
42
280
|
if (!snapshot) {
|
|
43
281
|
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
|
|
@@ -50,7 +288,8 @@ export class DurableChatSender {
|
|
|
50
288
|
/** Resolves after the exact wire message is durable, independently of network delivery. */
|
|
51
289
|
async enqueue(threadId, input) {
|
|
52
290
|
const message = await this.persistOutgoing(threadId, input);
|
|
53
|
-
|
|
291
|
+
if (!this.closed && !this.closing && !this.withdrawn)
|
|
292
|
+
void this.retry(message.hash).catch(error => this.onError(error));
|
|
54
293
|
return message;
|
|
55
294
|
}
|
|
56
295
|
/** Compatibility API: resolves only after transport delivery. */
|
|
@@ -58,23 +297,155 @@ export class DurableChatSender {
|
|
|
58
297
|
const message = await this.persistOutgoing(threadId, input);
|
|
59
298
|
return this.retry(message.hash);
|
|
60
299
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
300
|
+
persistOutgoing(threadId, input) {
|
|
301
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
302
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
303
|
+
if (input.voiceBlob && (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024))
|
|
304
|
+
return Promise.reject(new Error("INVALID_VOICE_ATTACHMENT"));
|
|
305
|
+
const reactionFloor = input.reactionRevisionFloor;
|
|
306
|
+
if (reactionFloor !== undefined && input.voiceBlob)
|
|
307
|
+
return Promise.reject(new Error("INVALID_MESSAGE_REACTION"));
|
|
308
|
+
if (reactionFloor !== undefined && (!Number.isSafeInteger(reactionFloor) || reactionFloor < 0 || reactionFloor >= Number.MAX_SAFE_INTEGER))
|
|
309
|
+
return Promise.reject(new Error("INVALID_REACTION_REVISION"));
|
|
310
|
+
let bytes;
|
|
311
|
+
try {
|
|
312
|
+
bytes = JSON.stringify([input.to, input.body, input.recipients, this.identity]).length * 3 + 1024 + (input.voiceBlob?.size ?? 0);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return Promise.reject(error);
|
|
316
|
+
}
|
|
317
|
+
if (bytes > 20 * 1024 * 1024)
|
|
318
|
+
return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
|
|
319
|
+
if (this.acceptanceCount >= 4096 || this.acceptanceBytes + bytes > 64 * 1024 * 1024)
|
|
320
|
+
return Promise.reject(new Error("OUTBOX_ACCEPTANCE_BACKPRESSURE"));
|
|
321
|
+
this.acceptanceCount++;
|
|
322
|
+
this.acceptanceBytes += bytes;
|
|
323
|
+
const pending = (async () => {
|
|
324
|
+
await this.ready;
|
|
325
|
+
await this.checkAccess();
|
|
326
|
+
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
327
|
+
if (message.from !== this.identity.profileId)
|
|
328
|
+
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
329
|
+
if (reactionFloor !== undefined) {
|
|
330
|
+
const body = message.body;
|
|
331
|
+
const reaction = body.kind === "message" ? body.content.reaction : undefined;
|
|
332
|
+
if (body.kind !== "message" || body.content.appId !== this.identity.appId || !reaction || reaction.version !== 1 || !validMessageIdentifier(reaction.messageId) || !validMessageIdentifier(reaction.emojiId) || typeof reaction.active !== "boolean")
|
|
333
|
+
throw new Error("INVALID_MESSAGE_REACTION");
|
|
334
|
+
}
|
|
335
|
+
const command = statefulGroup(message);
|
|
336
|
+
if (command)
|
|
337
|
+
message.hash = commandId(this.scope, this.version, threadId, message);
|
|
338
|
+
if (input.voiceBlob) {
|
|
339
|
+
const body = message.body;
|
|
340
|
+
if (body.kind !== "message" || !validVoice(body.content.voice))
|
|
341
|
+
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
342
|
+
}
|
|
343
|
+
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 };
|
|
344
|
+
const durable = await new Promise((resolve, reject) => {
|
|
345
|
+
this.acceptanceQueue.push({ record, reactionFloor, bytes, resolve, reject });
|
|
346
|
+
this.scheduleAcceptances();
|
|
347
|
+
});
|
|
348
|
+
return durable.message;
|
|
349
|
+
})().finally(() => { this.accepted.delete(pending); this.acceptanceCount--; this.acceptanceBytes -= bytes; });
|
|
350
|
+
this.accepted.add(pending);
|
|
351
|
+
return pending;
|
|
352
|
+
}
|
|
353
|
+
scheduleAcceptances() {
|
|
354
|
+
if (this.acceptanceTimer || this.acceptanceRunning || !this.acceptanceQueue.length)
|
|
355
|
+
return;
|
|
356
|
+
this.acceptanceTimer = setTimeout(() => { this.acceptanceTimer = undefined; void this.drainAcceptances(); }, 16);
|
|
357
|
+
}
|
|
358
|
+
failAcceptances(error) {
|
|
359
|
+
clearTimeout(this.acceptanceTimer);
|
|
360
|
+
this.acceptanceTimer = undefined;
|
|
361
|
+
for (const item of this.acceptanceQueue.splice(0))
|
|
362
|
+
item.reject(error);
|
|
363
|
+
}
|
|
364
|
+
drainAcceptances() {
|
|
365
|
+
const task = (async () => {
|
|
366
|
+
let bytes = 0, count = 0, operations = 0;
|
|
367
|
+
for (const item of this.acceptanceQueue) {
|
|
368
|
+
const size = item.bytes;
|
|
369
|
+
if (operations + (hasVoice(item.record) || item.reactionFloor !== undefined ? 2 : 1) > 128 || (count > 0 && bytes + size > 20 * 1024 * 1024))
|
|
370
|
+
break;
|
|
371
|
+
bytes += size;
|
|
372
|
+
count++;
|
|
373
|
+
operations += hasVoice(item.record) || item.reactionFloor !== undefined ? 2 : 1;
|
|
374
|
+
}
|
|
375
|
+
const batch = this.acceptanceQueue.splice(0, count);
|
|
376
|
+
try {
|
|
377
|
+
const result = await this.withEpoch("rw", async () => {
|
|
378
|
+
const commands = batch.filter(item => statefulGroup(item.record.message));
|
|
379
|
+
const previous = await this.outbox.bulkGet(commands.map(item => [this.scope, item.record.hash]));
|
|
380
|
+
const known = new Map(previous.filter((row) => Boolean(row && (row.accessVersion ?? 0) === this.version)).map(row => [row.hash, row]));
|
|
381
|
+
const writes = [], records = [], revisions = new Map();
|
|
382
|
+
for (const item of batch) {
|
|
383
|
+
let record = item.record;
|
|
384
|
+
if (item.reactionFloor !== undefined) {
|
|
385
|
+
const body = record.message.body;
|
|
386
|
+
const reaction = body.content.reaction;
|
|
387
|
+
const key = [this.scope, record.threadId, reaction.messageId, reaction.emojiId], encoded = JSON.stringify(key);
|
|
388
|
+
const previous = revisions.get(encoded) ?? await this.controlRevisions.get(key);
|
|
389
|
+
const revision = Math.max(item.reactionFloor, previous?.version === this.version ? previous.value : 0) + 1;
|
|
390
|
+
if (!Number.isSafeInteger(revision))
|
|
391
|
+
throw new Error("INVALID_REACTION_REVISION");
|
|
392
|
+
revisions.set(encoded, { scope: this.scope, threadId: record.threadId, target: reaction.messageId, emoji: reaction.emojiId, version: this.version, value: revision });
|
|
393
|
+
record = { ...record, message: { ...record.message, body: { ...body, content: { ...body.content, reaction: { ...reaction, revision } } } } };
|
|
394
|
+
}
|
|
395
|
+
const command = statefulGroup(record.message), old = command ? known.get(record.hash) : undefined;
|
|
396
|
+
if (old) {
|
|
397
|
+
if (old.message.from !== this.identity.profileId || old.message.hash !== record.hash || commandId(this.scope, this.version, record.threadId, old.message) !== record.hash)
|
|
398
|
+
throw new Error("OUTGOING_COMMAND_CONFLICT");
|
|
399
|
+
records.push(old);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
writes.push(record);
|
|
403
|
+
records.push(record);
|
|
404
|
+
if (command)
|
|
405
|
+
known.set(record.hash, record);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const split = writes.map(splitAttachment), payloads = split.flatMap(item => item.attachment ? [item.attachment] : []);
|
|
409
|
+
if (payloads.length)
|
|
410
|
+
await this.attachments.bulkPut(payloads);
|
|
411
|
+
if (revisions.size)
|
|
412
|
+
await this.controlRevisions.bulkPut([...revisions.values()]);
|
|
413
|
+
if (split.length)
|
|
414
|
+
await this.outbox.bulkPut(split.map(item => item.record));
|
|
415
|
+
const metadata = new Map(split.map(item => [item.record.hash, item.record]));
|
|
416
|
+
return { writes: split.map(item => item.record), records: records.map(record => metadata.get(record.hash) ?? record) };
|
|
417
|
+
});
|
|
418
|
+
await this.checkAccess();
|
|
419
|
+
for (const record of result.writes)
|
|
420
|
+
if (statefulGroup(record.message))
|
|
421
|
+
this.firstDelivery.add(record.hash);
|
|
422
|
+
let changed = false;
|
|
423
|
+
for (const record of result.records) {
|
|
424
|
+
if (record.confirmed) {
|
|
425
|
+
this.firstDelivery.delete(record.hash);
|
|
426
|
+
changed = this.values.delete(record.hash) || changed;
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
const current = this.values.get(record.hash);
|
|
430
|
+
if (!current || current.status !== record.status || current.accessVersion !== record.accessVersion) {
|
|
431
|
+
this.values.set(record.hash, record);
|
|
432
|
+
changed = true;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (changed)
|
|
437
|
+
this.emit();
|
|
438
|
+
batch.forEach((item, index) => item.resolve(result.records[index]));
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
batch.forEach(item => item.reject(error));
|
|
442
|
+
}
|
|
443
|
+
})().finally(() => { this.acceptanceRunning = undefined; this.scheduleAcceptances(); });
|
|
444
|
+
this.acceptanceRunning = task;
|
|
445
|
+
return task;
|
|
75
446
|
}
|
|
76
447
|
retry(hash) {
|
|
77
|
-
if (this.closed)
|
|
448
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
78
449
|
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
79
450
|
const running = this.running.get(hash);
|
|
80
451
|
if (running)
|
|
@@ -85,88 +456,392 @@ export class DurableChatSender {
|
|
|
85
456
|
}
|
|
86
457
|
async transmit(hash) {
|
|
87
458
|
await this.ready;
|
|
88
|
-
|
|
89
|
-
if (!record || this.closed)
|
|
459
|
+
let record = await this.queueIO(hash);
|
|
460
|
+
if (!record || this.closed || this.withdrawn)
|
|
90
461
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
91
|
-
if (record.status === "sent" || record.confirmed)
|
|
462
|
+
if (record.status === "sent" || record.confirmed) {
|
|
463
|
+
this.firstDelivery.delete(hash);
|
|
92
464
|
return record.message;
|
|
465
|
+
}
|
|
93
466
|
const sending = { ...record, status: "sending" };
|
|
94
467
|
if (record.status !== "sending") {
|
|
95
468
|
const latest = await this.save(sending);
|
|
96
469
|
if (!latest || latest.confirmed || latest.status === "sent")
|
|
97
470
|
return latest?.message ?? record.message;
|
|
98
471
|
}
|
|
472
|
+
await this.checkAccess();
|
|
99
473
|
if (this.values.get(hash)?.status !== "sending") {
|
|
100
474
|
this.values.set(hash, sending);
|
|
101
475
|
this.emit();
|
|
102
476
|
}
|
|
477
|
+
let transmitting = false;
|
|
103
478
|
try {
|
|
479
|
+
if (statefulGroup(record.message) && !this.firstDelivery.delete(hash)) {
|
|
480
|
+
const confirmed = await this.reconcile(record);
|
|
481
|
+
if (confirmed) {
|
|
482
|
+
await this.confirm([hash]);
|
|
483
|
+
return record.message;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (hasVoice(record)) {
|
|
487
|
+
record = await this.prepareVoice(record);
|
|
488
|
+
if (record.confirmed || record.status === "sent")
|
|
489
|
+
return record.message;
|
|
490
|
+
}
|
|
491
|
+
await this.checkAccess();
|
|
492
|
+
if (this.closing)
|
|
493
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
494
|
+
transmitting = true;
|
|
104
495
|
const message = await this.client.sendPrepared(record.message);
|
|
105
496
|
const sent = { ...record, message, status: "sent" };
|
|
106
|
-
|
|
107
|
-
if (!this.closed) {
|
|
108
|
-
if (committed?.confirmed || !committed)
|
|
109
|
-
this.values.delete(hash);
|
|
110
|
-
else
|
|
111
|
-
this.values.set(hash, committed);
|
|
112
|
-
this.emit();
|
|
113
|
-
}
|
|
497
|
+
await this.save(sent);
|
|
114
498
|
return message;
|
|
115
499
|
}
|
|
116
500
|
catch (error) {
|
|
117
|
-
|
|
501
|
+
// A failed delivery can already be durable remotely, including the first
|
|
502
|
+
// lost ACK. Proof recovery never sends a second wire in this attempt.
|
|
503
|
+
if (statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
|
|
504
|
+
try {
|
|
505
|
+
if (await this.reconcile(record)) {
|
|
506
|
+
await this.confirm([hash]);
|
|
507
|
+
return record.message;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (verificationError) {
|
|
511
|
+
error = new AggregateError([error, verificationError], "OUTBOX_RECONCILIATION_FAILED");
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
|
|
118
515
|
const failed = { ...record, status: "failed" };
|
|
119
516
|
const committed = await this.save(failed);
|
|
120
|
-
if (committed?.confirmed || committed?.status === "sent")
|
|
121
|
-
this.values.delete(hash);
|
|
122
|
-
this.emit();
|
|
517
|
+
if (committed?.confirmed || committed?.status === "sent")
|
|
123
518
|
return committed.message;
|
|
124
|
-
}
|
|
125
|
-
if (committed)
|
|
126
|
-
this.values.set(hash, committed);
|
|
127
|
-
else
|
|
128
|
-
this.values.delete(hash);
|
|
129
|
-
this.emit();
|
|
130
519
|
}
|
|
131
520
|
throw error;
|
|
132
521
|
}
|
|
133
522
|
}
|
|
134
|
-
async
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
523
|
+
async reconcile(record) {
|
|
524
|
+
await this.checkAccess();
|
|
525
|
+
if (this.closing || this.closed || this.withdrawn)
|
|
526
|
+
throw new Error("OUTBOX_RECONCILIATION_ABORTED");
|
|
527
|
+
if (!this.options.reconcile)
|
|
528
|
+
throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
|
|
529
|
+
const controller = new AbortController();
|
|
530
|
+
this.reconciliations.add(controller);
|
|
531
|
+
let abort;
|
|
532
|
+
const stopped = new Promise((_, reject) => { abort = () => reject(new Error("OUTBOX_RECONCILIATION_ABORTED")); controller.signal.addEventListener("abort", abort, { once: true }); });
|
|
533
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
534
|
+
try {
|
|
535
|
+
const confirmed = await Promise.race([this.options.reconcile({ threadId: record.threadId, message: record.message }, controller.signal), stopped]);
|
|
536
|
+
await this.checkAccess();
|
|
537
|
+
if (this.closing || controller.signal.aborted)
|
|
538
|
+
throw new Error("OUTBOX_RECONCILIATION_ABORTED");
|
|
539
|
+
return confirmed;
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
clearTimeout(timer);
|
|
543
|
+
controller.signal.removeEventListener("abort", abort);
|
|
544
|
+
this.reconciliations.delete(controller);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** Only explicit playback/transcription acquires a local payload, never snapshots. */
|
|
548
|
+
async acquireVoice(hash, threadId) {
|
|
549
|
+
await this.ready;
|
|
550
|
+
await this.checkAccess();
|
|
551
|
+
const record = await this.queueIO(hash);
|
|
552
|
+
if (!record || record.threadId !== threadId || !hasVoice(record))
|
|
553
|
+
return undefined;
|
|
554
|
+
const releaseBudget = this.reserveAttachment(record), key = Symbol(hash);
|
|
555
|
+
let url, released = false;
|
|
556
|
+
const release = () => { if (released)
|
|
557
|
+
return; released = true; if (url)
|
|
558
|
+
URL.revokeObjectURL(url); releaseBudget(); this.attachmentLeases.delete(key); };
|
|
559
|
+
this.attachmentLeases.set(key, release);
|
|
560
|
+
try {
|
|
561
|
+
const blob = await this.readVoice(record);
|
|
562
|
+
await this.checkAccess();
|
|
563
|
+
if (released || this.closing)
|
|
564
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
565
|
+
url = URL.createObjectURL(blob);
|
|
566
|
+
return { url, release };
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
release();
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
reserveAttachment(record) {
|
|
574
|
+
const size = record.voiceAttachment?.size ?? record.voiceData?.bytes.byteLength ?? record.voiceBlob?.size ?? 0;
|
|
575
|
+
if (!Number.isSafeInteger(size) || size <= 0 || size > 16 * 1024 * 1024)
|
|
576
|
+
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
577
|
+
// Charge both the native read buffer and Blob construction, including active leases.
|
|
578
|
+
const bytes = 2 * size;
|
|
579
|
+
if (this.attachmentBytes + bytes > 64 * 1024 * 1024)
|
|
580
|
+
throw new Error("VOICE_ATTACHMENT_BACKPRESSURE");
|
|
581
|
+
this.attachmentBytes += bytes;
|
|
582
|
+
let released = false;
|
|
583
|
+
return () => { if (!released) {
|
|
584
|
+
released = true;
|
|
585
|
+
this.attachmentBytes -= bytes;
|
|
586
|
+
} };
|
|
587
|
+
}
|
|
588
|
+
async readVoice(record) {
|
|
589
|
+
const row = await this.withEpoch("r", () => this.attachments.get([this.scope, record.hash]));
|
|
590
|
+
await this.checkAccess();
|
|
591
|
+
if (!row || row.accessVersion !== this.version || row.size !== record.voiceAttachment?.size || row.type !== record.voiceAttachment.type || (row.bytes?.byteLength ?? row.blob?.size) !== row.size)
|
|
592
|
+
throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
|
|
593
|
+
return row.blob ?? new Blob([row.bytes], { type: row.type });
|
|
594
|
+
}
|
|
595
|
+
async prepareVoice(record) {
|
|
596
|
+
if (this.uploadWaiting >= 32)
|
|
597
|
+
throw new Error("VOICE_UPLOAD_BACKPRESSURE");
|
|
598
|
+
this.uploadWaiting++;
|
|
599
|
+
const previous = this.uploadTail;
|
|
600
|
+
let release;
|
|
601
|
+
this.uploadTail = new Promise(resolve => { release = resolve; });
|
|
602
|
+
try {
|
|
603
|
+
await previous;
|
|
604
|
+
await this.checkAccess();
|
|
605
|
+
if (this.closing)
|
|
606
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
607
|
+
const latest = await this.queueIO(record.hash);
|
|
608
|
+
if (!latest)
|
|
609
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
610
|
+
if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
|
|
611
|
+
return latest;
|
|
612
|
+
if (!this.options.uploadVoice)
|
|
613
|
+
throw new Error("VOICE_UPLOAD_UNAVAILABLE");
|
|
614
|
+
const release = this.reserveAttachment(latest);
|
|
615
|
+
const controller = new AbortController();
|
|
616
|
+
this.uploads.add(controller);
|
|
617
|
+
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
618
|
+
let url;
|
|
619
|
+
try {
|
|
620
|
+
const blob = await this.readVoice(latest);
|
|
621
|
+
if (controller.signal.aborted || this.closing)
|
|
622
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
623
|
+
url = await this.options.uploadVoice(blob, controller.signal);
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
release();
|
|
627
|
+
clearTimeout(timeout);
|
|
628
|
+
this.uploads.delete(controller);
|
|
629
|
+
}
|
|
630
|
+
const parsed = new URL(url);
|
|
631
|
+
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
632
|
+
throw new Error("INVALID_VOICE_UPLOAD_URL");
|
|
633
|
+
await this.checkAccess();
|
|
634
|
+
if (this.closing)
|
|
635
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
636
|
+
const { voiceBlob: _blob, voiceData: _data, voiceAttachment: _attachment, ...uploaded } = latest;
|
|
637
|
+
const committed = await this.queueIO(record.hash, { ...uploaded, message: withVoiceUrl(latest.message, url) }, true);
|
|
638
|
+
if (!committed)
|
|
639
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
640
|
+
return committed;
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
this.uploadWaiting--;
|
|
644
|
+
release();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
save(record) { return this.queueIO(record.hash, record); }
|
|
648
|
+
queueIO(hash, record, uploaded = false) {
|
|
649
|
+
if (this.closed || this.withdrawn)
|
|
650
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
651
|
+
const value = record ?? this.values.get(hash);
|
|
652
|
+
const bytes = value ? JSON.stringify(value.message).length * 3 + (value.voiceData?.bytes.byteLength ?? value.voiceBlob?.size ?? 0) : 0;
|
|
653
|
+
if (bytes > 20 * 1024 * 1024)
|
|
654
|
+
return Promise.reject(new Error("OUTBOX_RECORD_TOO_LARGE"));
|
|
655
|
+
if (this.ioCount >= 4096 || this.ioBytes + bytes > 64 * 1024 * 1024)
|
|
656
|
+
return Promise.reject(new Error("OUTBOX_IO_BACKPRESSURE"));
|
|
657
|
+
this.ioCount++;
|
|
658
|
+
this.ioBytes += bytes;
|
|
659
|
+
return new Promise((resolve, reject) => {
|
|
660
|
+
this.ioQueue.push({ hash, record, uploaded, bytes, resolve, reject });
|
|
661
|
+
this.scheduleIO();
|
|
662
|
+
}).finally(() => { this.ioCount--; this.ioBytes -= bytes; });
|
|
663
|
+
}
|
|
664
|
+
scheduleIO() {
|
|
665
|
+
if (this.ioTimer || this.ioRunning || !this.ioQueue.length)
|
|
666
|
+
return;
|
|
667
|
+
this.ioTimer = setTimeout(() => { this.ioTimer = undefined; void this.drainIO(); }, 16);
|
|
668
|
+
}
|
|
669
|
+
failIO(error) {
|
|
670
|
+
clearTimeout(this.ioTimer);
|
|
671
|
+
this.ioTimer = undefined;
|
|
672
|
+
for (const item of this.ioQueue.splice(0))
|
|
673
|
+
item.reject(error);
|
|
674
|
+
}
|
|
675
|
+
drainIO() {
|
|
676
|
+
const task = (async () => {
|
|
677
|
+
let count = 0, bytes = 0, operations = 0;
|
|
678
|
+
for (const item of this.ioQueue) {
|
|
679
|
+
if (operations + (item.uploaded ? 2 : 1) > 128 || (count > 0 && bytes + item.bytes > 20 * 1024 * 1024))
|
|
680
|
+
break;
|
|
681
|
+
count++;
|
|
682
|
+
bytes += item.bytes;
|
|
683
|
+
operations += item.uploaded ? 2 : 1;
|
|
684
|
+
}
|
|
685
|
+
const batch = this.ioQueue.splice(0, count);
|
|
686
|
+
try {
|
|
687
|
+
const records = await this.withEpoch(batch.some(item => item.record) ? "rw" : "r", async () => {
|
|
688
|
+
const hashes = [...new Set(batch.map(item => item.hash))];
|
|
689
|
+
const rows = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
|
|
690
|
+
const current = new Map(hashes.map((hash, index) => [hash, rows[index]]));
|
|
691
|
+
const writes = new Map(), result = [];
|
|
692
|
+
for (const item of batch) {
|
|
693
|
+
const previous = current.get(item.hash);
|
|
694
|
+
// Preserve another document's confirmation, sent status or uploaded URL.
|
|
695
|
+
if (item.record && previous && !previous.confirmed && previous.status !== "sent" && !(hasVoice(item.record) && !hasVoice(previous)) && !(item.uploaded && !hasVoice(previous))) {
|
|
696
|
+
current.set(item.hash, item.record);
|
|
697
|
+
writes.set(item.hash, item.record);
|
|
698
|
+
}
|
|
699
|
+
result.push(current.get(item.hash));
|
|
700
|
+
}
|
|
701
|
+
if (writes.size) {
|
|
702
|
+
await this.outbox.bulkPut([...writes.values()]);
|
|
703
|
+
const released = [...writes.values()].filter(record => !hasVoice(record) && hasVoice(rows[hashes.indexOf(record.hash)]));
|
|
704
|
+
if (released.length)
|
|
705
|
+
await this.attachments.bulkDelete(released.map(record => [this.scope, record.hash]));
|
|
706
|
+
}
|
|
707
|
+
return result;
|
|
708
|
+
});
|
|
709
|
+
await this.checkAccess();
|
|
710
|
+
let changed = false;
|
|
711
|
+
batch.forEach((item, index) => {
|
|
712
|
+
if (!item.record)
|
|
713
|
+
return;
|
|
714
|
+
const record = records[index], old = this.values.get(item.hash);
|
|
715
|
+
if (!record || record.confirmed) {
|
|
716
|
+
changed = this.values.delete(item.hash) || changed;
|
|
717
|
+
}
|
|
718
|
+
else if (!old || old.status !== record.status || old.accessVersion !== record.accessVersion || JSON.stringify(old.message) !== JSON.stringify(record.message) || hasVoice(old) !== hasVoice(record)) {
|
|
719
|
+
this.values.set(item.hash, record);
|
|
720
|
+
changed = true;
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
if (changed)
|
|
724
|
+
this.emit();
|
|
725
|
+
batch.forEach((item, index) => item.resolve(records[index]));
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
batch.forEach(item => item.reject(error));
|
|
729
|
+
}
|
|
730
|
+
})().finally(() => { this.ioRunning = undefined; this.scheduleIO(); });
|
|
731
|
+
this.ioRunning = task;
|
|
732
|
+
return task;
|
|
142
733
|
}
|
|
143
734
|
/** Confirm only after the authoritative message has been durably merged.
|
|
144
735
|
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
145
|
-
|
|
736
|
+
confirm(hashes) {
|
|
146
737
|
hashes = [...new Set(hashes)];
|
|
147
738
|
if (!hashes.length)
|
|
739
|
+
return Promise.resolve();
|
|
740
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
741
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
742
|
+
if (this.confirmationSize + hashes.length > 4096)
|
|
743
|
+
return Promise.reject(new Error("OUTBOX_CONFIRM_BACKPRESSURE"));
|
|
744
|
+
const pending = new Promise((resolve, reject) => this.confirmationQueue.push({ hashes, resolve, reject }));
|
|
745
|
+
this.confirmationSize += hashes.length;
|
|
746
|
+
this.scheduleConfirmations();
|
|
747
|
+
return pending;
|
|
748
|
+
}
|
|
749
|
+
scheduleConfirmations() {
|
|
750
|
+
if (this.confirmationTimer !== undefined || this.confirmationRunning || !this.confirmationQueue.length)
|
|
148
751
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
752
|
+
this.confirmationTimer = setTimeout(() => { this.confirmationTimer = undefined; void this.drainConfirmations(); }, 16);
|
|
753
|
+
}
|
|
754
|
+
drainConfirmations() {
|
|
755
|
+
if (this.confirmationRunning)
|
|
756
|
+
return this.confirmationRunning;
|
|
757
|
+
const batch = this.confirmationQueue.splice(0);
|
|
758
|
+
if (!batch.length)
|
|
759
|
+
return Promise.resolve();
|
|
760
|
+
const size = batch.reduce((sum, item) => sum + item.hashes.length, 0);
|
|
761
|
+
return this.confirmationRunning = (async () => {
|
|
762
|
+
let failed = false, error;
|
|
763
|
+
try {
|
|
764
|
+
await this.confirmNow([...new Set(batch.flatMap(item => item.hashes))]);
|
|
765
|
+
}
|
|
766
|
+
catch (reason) {
|
|
767
|
+
failed = true;
|
|
768
|
+
error = reason;
|
|
769
|
+
}
|
|
770
|
+
this.confirmationSize -= size;
|
|
771
|
+
for (const item of batch)
|
|
772
|
+
if (failed)
|
|
773
|
+
item.reject(error);
|
|
774
|
+
else
|
|
775
|
+
item.resolve();
|
|
776
|
+
})().finally(() => {
|
|
777
|
+
this.confirmationRunning = undefined;
|
|
778
|
+
this.scheduleConfirmations();
|
|
155
779
|
});
|
|
780
|
+
}
|
|
781
|
+
failConfirmations(error) {
|
|
782
|
+
clearTimeout(this.confirmationTimer);
|
|
783
|
+
this.confirmationTimer = undefined;
|
|
784
|
+
for (const item of this.confirmationQueue.splice(0)) {
|
|
785
|
+
this.confirmationSize -= item.hashes.length;
|
|
786
|
+
item.reject(error);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
async flushConfirmations() {
|
|
790
|
+
while (this.confirmationRunning || this.confirmationQueue.length) {
|
|
791
|
+
clearTimeout(this.confirmationTimer);
|
|
792
|
+
this.confirmationTimer = undefined;
|
|
793
|
+
await (this.confirmationRunning ?? this.drainConfirmations());
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
async confirmNow(hashes) {
|
|
797
|
+
await this.ready;
|
|
156
798
|
let changed = false;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
799
|
+
try {
|
|
800
|
+
for (let offset = 0; offset < hashes.length;) {
|
|
801
|
+
let end = offset, operations = 0;
|
|
802
|
+
while (end < hashes.length) {
|
|
803
|
+
const known = this.values.get(hashes[end]), cost = known && !hasVoice(known) ? 1 : 2;
|
|
804
|
+
if (operations + cost > 128)
|
|
805
|
+
break;
|
|
806
|
+
operations += cost;
|
|
807
|
+
end++;
|
|
808
|
+
}
|
|
809
|
+
const batch = hashes.slice(offset, end);
|
|
810
|
+
await this.withEpoch("rw", async () => {
|
|
811
|
+
const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
|
|
812
|
+
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, voiceAttachment: undefined, status: "sent", confirmed: true }));
|
|
813
|
+
if (confirmed.length) {
|
|
814
|
+
await this.outbox.bulkPut(confirmed);
|
|
815
|
+
const voices = previous.filter((record) => Boolean(record && hasVoice(record)));
|
|
816
|
+
if (voices.length)
|
|
817
|
+
await this.attachments.bulkDelete(voices.map(record => [this.scope, record.hash]));
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
// Retire only committed rows. A later failed transaction leaves the
|
|
821
|
+
// remaining sends retryable, including after a browser restart.
|
|
822
|
+
for (const hash of batch) {
|
|
823
|
+
changed = this.values.delete(hash) || changed;
|
|
824
|
+
}
|
|
825
|
+
offset = end;
|
|
826
|
+
if (offset < hashes.length)
|
|
827
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
finally {
|
|
831
|
+
// One confirmation operation publishes at most once, also on partial
|
|
832
|
+
// failure so consumers see exactly the progress that was committed.
|
|
833
|
+
if (changed)
|
|
834
|
+
this.emit();
|
|
835
|
+
}
|
|
161
836
|
}
|
|
162
837
|
async resume() {
|
|
163
838
|
await this.ready;
|
|
164
|
-
if (this.closed)
|
|
839
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
165
840
|
return;
|
|
166
841
|
// Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
|
|
167
842
|
for (const record of this.values.values())
|
|
168
843
|
if (record.status === "sending") {
|
|
169
|
-
if (this.closed)
|
|
844
|
+
if (this.closed || this.withdrawn)
|
|
170
845
|
return;
|
|
171
846
|
try {
|
|
172
847
|
await this.retry(record.hash);
|
|
@@ -177,11 +852,51 @@ export class DurableChatSender {
|
|
|
177
852
|
}
|
|
178
853
|
}
|
|
179
854
|
async close() {
|
|
180
|
-
this.
|
|
855
|
+
this.closing = true;
|
|
856
|
+
this.attachmentLeases.forEach(release => release());
|
|
857
|
+
this.uploads.forEach(controller => controller.abort());
|
|
858
|
+
this.reconciliations.forEach(controller => controller.abort());
|
|
181
859
|
this.offConnection();
|
|
182
860
|
this.listeners.clear();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
861
|
+
this.channel?.close();
|
|
862
|
+
this.channel = undefined;
|
|
863
|
+
try {
|
|
864
|
+
await this.ready;
|
|
865
|
+
await this.flushConfirmations();
|
|
866
|
+
await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
this.closed = true;
|
|
870
|
+
this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
|
|
871
|
+
this.firstDelivery.clear();
|
|
872
|
+
this.attachmentLeases.forEach(release => release());
|
|
873
|
+
this.values.clear();
|
|
874
|
+
this.snapshots.clear();
|
|
875
|
+
this.db.close();
|
|
876
|
+
}
|
|
186
877
|
}
|
|
187
878
|
}
|
|
879
|
+
function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
|
|
880
|
+
function commandId(scope, version, threadId, message) {
|
|
881
|
+
return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
|
|
882
|
+
}
|
|
883
|
+
function ordered(value) {
|
|
884
|
+
if (Array.isArray(value))
|
|
885
|
+
return value.map(ordered);
|
|
886
|
+
if (value && typeof value === "object")
|
|
887
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, ordered(item)]));
|
|
888
|
+
return value;
|
|
889
|
+
}
|
|
890
|
+
function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
|
|
891
|
+
function withVoiceUrl(message, url) {
|
|
892
|
+
const body = message.body;
|
|
893
|
+
return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
|
|
894
|
+
}
|
|
895
|
+
function hasVoice(record) { return Boolean(record && (record.voiceBlob || record.voiceData || record.voiceAttachment)); }
|
|
896
|
+
function splitAttachment(record) {
|
|
897
|
+
const { voiceBlob, voiceData, ...metadata } = record;
|
|
898
|
+
if (!voiceBlob && !voiceData)
|
|
899
|
+
return { record: metadata };
|
|
900
|
+
const size = voiceData?.bytes.byteLength ?? voiceBlob.size, type = voiceData?.type ?? voiceBlob.type;
|
|
901
|
+
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 }) } };
|
|
902
|
+
}
|