@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.51
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 +34 -1
- 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 +1 -0
- package/lib/runtime/chatClient.js +1 -1
- package/lib/runtime/chatSession.d.ts +78 -13
- package/lib/runtime/chatSession.js +494 -43
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -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 +5 -5
- package/lib/runtime/createChat.js +16 -17
- package/lib/runtime/durableSender.d.ts +63 -14
- package/lib/runtime/durableSender.js +419 -45
- package/lib/runtime/index.d.ts +4 -0
- package/lib/runtime/index.js +4 -0
- package/lib/runtime/messageClient.d.ts +32 -8
- package/lib/runtime/messageClient.js +415 -75
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +42 -0
- package/lib/runtime/messageLaunch.js +133 -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 +218 -10
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +96 -8
- package/lib/runtime/readIndex.d.ts +55 -6
- package/lib/runtime/readIndex.js +363 -14
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +25 -8
- 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/recordCache.d.ts +9 -0
- package/lib/runtime/recordCache.js +62 -5
- package/lib/runtime/responseCache.d.ts +44 -0
- package/lib/runtime/responseCache.js +173 -0
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +20 -0
- package/package.json +6 -1
|
@@ -5,8 +5,24 @@ import { messageScopeKey } from "./messageStore.js";
|
|
|
5
5
|
export class DurableChatSender {
|
|
6
6
|
client;
|
|
7
7
|
onError;
|
|
8
|
+
options;
|
|
8
9
|
db;
|
|
9
10
|
outbox;
|
|
11
|
+
epochs;
|
|
12
|
+
localUrls = new Map();
|
|
13
|
+
localViews = new WeakMap();
|
|
14
|
+
uploads = new Set();
|
|
15
|
+
uploadTail = Promise.resolve();
|
|
16
|
+
uploadWaiting = 0;
|
|
17
|
+
confirmationQueue = [];
|
|
18
|
+
confirmationTimer;
|
|
19
|
+
confirmationRunning;
|
|
20
|
+
confirmationSize = 0;
|
|
21
|
+
version = 0;
|
|
22
|
+
withdrawn = false;
|
|
23
|
+
revoking;
|
|
24
|
+
channel;
|
|
25
|
+
accepted = new Set();
|
|
10
26
|
scope;
|
|
11
27
|
identity;
|
|
12
28
|
values = new Map();
|
|
@@ -14,33 +30,198 @@ export class DurableChatSender {
|
|
|
14
30
|
listeners = new Set();
|
|
15
31
|
running = new Map();
|
|
16
32
|
closed = false;
|
|
33
|
+
closing = false;
|
|
34
|
+
empty = [];
|
|
17
35
|
offConnection;
|
|
18
36
|
ready;
|
|
19
37
|
constructor(client, scope, onError, options = {}) {
|
|
20
38
|
this.client = client;
|
|
21
39
|
this.onError = onError;
|
|
40
|
+
this.options = options;
|
|
22
41
|
this.scope = messageScopeKey(scope);
|
|
23
42
|
this.identity = { ...scope };
|
|
24
43
|
this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
25
44
|
this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
45
|
+
this.db.version(2).stores({ scopeEpochs: "scope" });
|
|
46
|
+
this.db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
26
47
|
this.outbox = this.db.table("outbox");
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
});
|
|
48
|
+
this.epochs = this.db.table("scopeEpochs");
|
|
49
|
+
this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
|
|
50
|
+
this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
|
|
51
|
+
this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
|
|
52
|
+
this.ready = this.restore();
|
|
53
|
+
void this.ready.catch(() => { });
|
|
54
|
+
if (typeof BroadcastChannel !== "undefined") {
|
|
55
|
+
this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
|
|
56
|
+
this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
|
|
57
|
+
this.withdraw(); };
|
|
58
|
+
}
|
|
33
59
|
this.offConnection = client.watchConnection(state => {
|
|
34
60
|
if (state === "connected")
|
|
35
61
|
void this.resume().catch(onError);
|
|
36
62
|
});
|
|
37
63
|
}
|
|
64
|
+
/** Read only the selected durable rows, without restoring or delivering an outbox. */
|
|
65
|
+
static async readSelected(scope, threadId, ids, version, options = {}) {
|
|
66
|
+
if (ids.length > 100)
|
|
67
|
+
throw new Error("INVALID_OUTBOX_SELECTION");
|
|
68
|
+
const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
69
|
+
db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
|
|
70
|
+
db.version(2).stores({ scopeEpochs: "scope" });
|
|
71
|
+
db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
|
|
72
|
+
try {
|
|
73
|
+
return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
|
|
74
|
+
if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
|
|
75
|
+
return [];
|
|
76
|
+
const rows = await db.table("outbox").bulkGet(ids.map(id => [key, id]));
|
|
77
|
+
return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [row] : []);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
db.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async readAccess() {
|
|
85
|
+
const access = await this.options.access?.();
|
|
86
|
+
if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
|
|
87
|
+
throw new Error("INVALID_OUTBOX_ACCESS");
|
|
88
|
+
return access;
|
|
89
|
+
}
|
|
90
|
+
async restore() {
|
|
91
|
+
const access = await this.readAccess();
|
|
92
|
+
const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
93
|
+
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
94
|
+
const version = access?.version ?? current;
|
|
95
|
+
if (version < current)
|
|
96
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
97
|
+
if (version > current) {
|
|
98
|
+
await this.epochs.put({ scope: this.scope, version });
|
|
99
|
+
await this.deleteRows();
|
|
100
|
+
}
|
|
101
|
+
const total = await this.outbox.where("scope").equals(this.scope).count();
|
|
102
|
+
const indexed = await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, Dexie.minKey], [this.scope, 1, Dexie.maxKey], true, true).count();
|
|
103
|
+
return { version, advanced: version > current, legacy: total !== indexed };
|
|
104
|
+
});
|
|
105
|
+
this.version = loaded.version;
|
|
106
|
+
if (loaded.advanced)
|
|
107
|
+
this.channel?.postMessage({ version: this.version });
|
|
108
|
+
if (this.closed || this.withdrawn)
|
|
109
|
+
return;
|
|
110
|
+
const current = await this.readAccess();
|
|
111
|
+
if (current && (!current.allowed || current.version !== this.version))
|
|
112
|
+
return;
|
|
113
|
+
if (access?.allowed === false)
|
|
114
|
+
return;
|
|
115
|
+
let after;
|
|
116
|
+
const restored = new Map();
|
|
117
|
+
while (!this.closed && !this.withdrawn) {
|
|
118
|
+
const batch = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
119
|
+
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
120
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
121
|
+
const keys = await (loaded.legacy
|
|
122
|
+
? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
|
|
123
|
+
: this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(128).primaryKeys();
|
|
124
|
+
if (!keys.length)
|
|
125
|
+
return { last: undefined, records: [] };
|
|
126
|
+
const last = keys.at(-1)[1];
|
|
127
|
+
// Key-only metadata covers confirmed history without reading wire bodies.
|
|
128
|
+
// Missing metadata identifies rows written by an older SDK after upgrade.
|
|
129
|
+
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() : [];
|
|
130
|
+
const confirmedHashes = new Set(confirmed.map(key => key[1]));
|
|
131
|
+
const needed = keys.filter(key => !confirmedHashes.has(key[1]));
|
|
132
|
+
const rows = (await this.outbox.bulkGet(needed)).filter((row) => Boolean(row));
|
|
133
|
+
const repairs = rows.filter(row => row.pendingState !== (row.confirmed ? 0 : 1));
|
|
134
|
+
if (repairs.length)
|
|
135
|
+
await this.outbox.bulkPut(repairs.map(row => ({ ...row, pendingState: row.confirmed ? 0 : 1 })));
|
|
136
|
+
return { last, records: rows.filter(row => !row.confirmed && (row.accessVersion ?? 0) === this.version) };
|
|
137
|
+
});
|
|
138
|
+
for (const row of batch.records)
|
|
139
|
+
restored.set(row.hash, row);
|
|
140
|
+
if (batch.last === undefined)
|
|
141
|
+
break;
|
|
142
|
+
after = batch.last;
|
|
143
|
+
// Leave the database transaction before yielding to input/rendering work.
|
|
144
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
145
|
+
}
|
|
146
|
+
if (this.closed || this.withdrawn)
|
|
147
|
+
return;
|
|
148
|
+
const latest = await this.readAccess();
|
|
149
|
+
if (latest && (!latest.allowed || latest.version !== this.version))
|
|
150
|
+
return;
|
|
151
|
+
this.values = restored;
|
|
152
|
+
this.emit();
|
|
153
|
+
}
|
|
154
|
+
async checkAccess() {
|
|
155
|
+
if (this.closed)
|
|
156
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
157
|
+
if (this.withdrawn)
|
|
158
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
159
|
+
const access = await this.readAccess();
|
|
160
|
+
if (access && access.version !== this.version) {
|
|
161
|
+
this.withdraw();
|
|
162
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
163
|
+
}
|
|
164
|
+
if (access && !access.allowed)
|
|
165
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
166
|
+
}
|
|
167
|
+
async withEpoch(mode, operation) {
|
|
168
|
+
await this.checkAccess();
|
|
169
|
+
try {
|
|
170
|
+
return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
|
|
171
|
+
if (this.closed || this.withdrawn)
|
|
172
|
+
throw new Error("OUTBOX_ACCESS_REVOKED");
|
|
173
|
+
if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
|
|
174
|
+
throw new Error("OUTBOX_SCOPE_CHANGED");
|
|
175
|
+
return operation();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
180
|
+
this.withdraw();
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
deleteRows() { return this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete(); }
|
|
185
|
+
/** Stop disclosure immediately; durable invalidation is coordinated separately. */
|
|
186
|
+
withdraw() {
|
|
187
|
+
this.withdrawn = true;
|
|
188
|
+
this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
189
|
+
this.uploads.forEach(controller => controller.abort());
|
|
190
|
+
this.releaseUrls();
|
|
191
|
+
this.values.clear();
|
|
192
|
+
this.emit();
|
|
193
|
+
}
|
|
194
|
+
revoke(targetVersion) {
|
|
195
|
+
if (this.closed || this.closing)
|
|
196
|
+
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
197
|
+
if (this.revoking)
|
|
198
|
+
return this.revoking;
|
|
199
|
+
this.withdraw();
|
|
200
|
+
return this.revoking = (async () => {
|
|
201
|
+
await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
|
|
202
|
+
throw error; });
|
|
203
|
+
const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
|
|
204
|
+
const current = (await this.epochs.get(this.scope))?.version ?? 0;
|
|
205
|
+
const target = targetVersion ?? this.version + 1;
|
|
206
|
+
if (!Number.isSafeInteger(target) || target < 0)
|
|
207
|
+
throw new Error("INVALID_OUTBOX_EPOCH");
|
|
208
|
+
if (current >= target)
|
|
209
|
+
return current;
|
|
210
|
+
await this.epochs.put({ scope: this.scope, version: target });
|
|
211
|
+
await this.deleteRows();
|
|
212
|
+
return target;
|
|
213
|
+
});
|
|
214
|
+
this.channel?.postMessage({ version });
|
|
215
|
+
})();
|
|
216
|
+
}
|
|
38
217
|
initialize() { return this.ready; }
|
|
39
218
|
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
40
219
|
snapshot(threadId) {
|
|
220
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
221
|
+
return this.empty;
|
|
41
222
|
let snapshot = this.snapshots.get(threadId);
|
|
42
223
|
if (!snapshot) {
|
|
43
|
-
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
|
|
224
|
+
snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId).map(record => this.localView(record));
|
|
44
225
|
this.snapshots.set(threadId, snapshot);
|
|
45
226
|
}
|
|
46
227
|
return snapshot;
|
|
@@ -50,7 +231,8 @@ export class DurableChatSender {
|
|
|
50
231
|
/** Resolves after the exact wire message is durable, independently of network delivery. */
|
|
51
232
|
async enqueue(threadId, input) {
|
|
52
233
|
const message = await this.persistOutgoing(threadId, input);
|
|
53
|
-
|
|
234
|
+
if (!this.closed && !this.closing && !this.withdrawn)
|
|
235
|
+
void this.retry(message.hash).catch(error => this.onError(error));
|
|
54
236
|
return message;
|
|
55
237
|
}
|
|
56
238
|
/** Compatibility API: resolves only after transport delivery. */
|
|
@@ -58,23 +240,32 @@ export class DurableChatSender {
|
|
|
58
240
|
const message = await this.persistOutgoing(threadId, input);
|
|
59
241
|
return this.retry(message.hash);
|
|
60
242
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
243
|
+
persistOutgoing(threadId, input) {
|
|
244
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
245
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
246
|
+
const pending = (async () => {
|
|
247
|
+
await this.ready;
|
|
248
|
+
await this.checkAccess();
|
|
249
|
+
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
250
|
+
if (message.from !== this.identity.profileId)
|
|
251
|
+
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
252
|
+
if (input.voiceBlob) {
|
|
253
|
+
const body = message.body;
|
|
254
|
+
if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
|
|
255
|
+
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
256
|
+
}
|
|
257
|
+
const record = { ...(input.voiceBlob ? { voiceData: { bytes: await input.voiceBlob.arrayBuffer(), type: input.voiceBlob.type } } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
|
|
258
|
+
await this.withEpoch("rw", async () => { await this.outbox.put(record); });
|
|
259
|
+
await this.checkAccess();
|
|
260
|
+
this.values.set(record.hash, record);
|
|
261
|
+
this.emit();
|
|
262
|
+
return message;
|
|
263
|
+
})().finally(() => this.accepted.delete(pending));
|
|
264
|
+
this.accepted.add(pending);
|
|
265
|
+
return pending;
|
|
75
266
|
}
|
|
76
267
|
retry(hash) {
|
|
77
|
-
if (this.closed)
|
|
268
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
78
269
|
return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
|
|
79
270
|
const running = this.running.get(hash);
|
|
80
271
|
if (running)
|
|
@@ -85,8 +276,8 @@ export class DurableChatSender {
|
|
|
85
276
|
}
|
|
86
277
|
async transmit(hash) {
|
|
87
278
|
await this.ready;
|
|
88
|
-
|
|
89
|
-
if (!record || this.closed)
|
|
279
|
+
let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
|
|
280
|
+
if (!record || this.closed || this.withdrawn)
|
|
90
281
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
91
282
|
if (record.status === "sent" || record.confirmed)
|
|
92
283
|
return record.message;
|
|
@@ -96,15 +287,24 @@ export class DurableChatSender {
|
|
|
96
287
|
if (!latest || latest.confirmed || latest.status === "sent")
|
|
97
288
|
return latest?.message ?? record.message;
|
|
98
289
|
}
|
|
290
|
+
await this.checkAccess();
|
|
99
291
|
if (this.values.get(hash)?.status !== "sending") {
|
|
100
292
|
this.values.set(hash, sending);
|
|
101
293
|
this.emit();
|
|
102
294
|
}
|
|
103
295
|
try {
|
|
296
|
+
if (hasVoice(record)) {
|
|
297
|
+
record = await this.prepareVoice(record);
|
|
298
|
+
if (record.confirmed || record.status === "sent")
|
|
299
|
+
return record.message;
|
|
300
|
+
}
|
|
301
|
+
await this.checkAccess();
|
|
302
|
+
if (this.closing)
|
|
303
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
104
304
|
const message = await this.client.sendPrepared(record.message);
|
|
105
305
|
const sent = { ...record, message, status: "sent" };
|
|
106
306
|
const committed = await this.save(sent);
|
|
107
|
-
if (!this.closed) {
|
|
307
|
+
if (!this.closed && !this.withdrawn) {
|
|
108
308
|
if (committed?.confirmed || !committed)
|
|
109
309
|
this.values.delete(hash);
|
|
110
310
|
else
|
|
@@ -114,7 +314,7 @@ export class DurableChatSender {
|
|
|
114
314
|
return message;
|
|
115
315
|
}
|
|
116
316
|
catch (error) {
|
|
117
|
-
if (!this.closed) {
|
|
317
|
+
if (!this.closed && !this.withdrawn) {
|
|
118
318
|
const failed = { ...record, status: "failed" };
|
|
119
319
|
const committed = await this.save(failed);
|
|
120
320
|
if (committed?.confirmed || committed?.status === "sent") {
|
|
@@ -131,42 +331,196 @@ export class DurableChatSender {
|
|
|
131
331
|
throw error;
|
|
132
332
|
}
|
|
133
333
|
}
|
|
334
|
+
releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
|
|
335
|
+
URL.revokeObjectURL(value.url);
|
|
336
|
+
this.localUrls.delete(hash);
|
|
337
|
+
} }
|
|
338
|
+
releaseUrls() { for (const hash of this.localUrls.keys())
|
|
339
|
+
this.releaseUrl(hash); }
|
|
340
|
+
localView(record) {
|
|
341
|
+
if (!hasVoice(record) || typeof URL.createObjectURL !== "function")
|
|
342
|
+
return record;
|
|
343
|
+
let cached = this.localViews.get(record);
|
|
344
|
+
if (cached)
|
|
345
|
+
return cached;
|
|
346
|
+
const blob = voiceBlob(record);
|
|
347
|
+
let local = this.localUrls.get(record.hash);
|
|
348
|
+
if (!local || local.blob !== blob) {
|
|
349
|
+
this.releaseUrl(record.hash);
|
|
350
|
+
local = { blob, url: URL.createObjectURL(blob) };
|
|
351
|
+
this.localUrls.set(record.hash, local);
|
|
352
|
+
}
|
|
353
|
+
cached = { ...record, voiceBlob: blob, message: withVoiceUrl(record.message, local.url) };
|
|
354
|
+
this.localViews.set(record, cached);
|
|
355
|
+
return cached;
|
|
356
|
+
}
|
|
357
|
+
async prepareVoice(record) {
|
|
358
|
+
if (this.uploadWaiting >= 32)
|
|
359
|
+
throw new Error("VOICE_UPLOAD_BACKPRESSURE");
|
|
360
|
+
this.uploadWaiting++;
|
|
361
|
+
const previous = this.uploadTail;
|
|
362
|
+
let release;
|
|
363
|
+
this.uploadTail = new Promise(resolve => { release = resolve; });
|
|
364
|
+
try {
|
|
365
|
+
await previous;
|
|
366
|
+
await this.checkAccess();
|
|
367
|
+
if (this.closing)
|
|
368
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
369
|
+
const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
|
|
370
|
+
if (!latest)
|
|
371
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
372
|
+
if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
|
|
373
|
+
return latest;
|
|
374
|
+
if (!this.options.uploadVoice)
|
|
375
|
+
throw new Error("VOICE_UPLOAD_UNAVAILABLE");
|
|
376
|
+
const controller = new AbortController();
|
|
377
|
+
this.uploads.add(controller);
|
|
378
|
+
const timeout = setTimeout(() => controller.abort(), 30_000);
|
|
379
|
+
let url;
|
|
380
|
+
try {
|
|
381
|
+
url = await this.options.uploadVoice(voiceBlob(latest), controller.signal);
|
|
382
|
+
}
|
|
383
|
+
finally {
|
|
384
|
+
clearTimeout(timeout);
|
|
385
|
+
this.uploads.delete(controller);
|
|
386
|
+
}
|
|
387
|
+
const parsed = new URL(url);
|
|
388
|
+
if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
389
|
+
throw new Error("INVALID_VOICE_UPLOAD_URL");
|
|
390
|
+
await this.checkAccess();
|
|
391
|
+
if (this.closing)
|
|
392
|
+
throw new Error("CHAT_SENDER_CLOSED");
|
|
393
|
+
const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
|
|
394
|
+
const committed = await this.withEpoch("rw", async () => {
|
|
395
|
+
const current = await this.outbox.get([this.scope, record.hash]);
|
|
396
|
+
if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
|
|
397
|
+
return current;
|
|
398
|
+
const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
|
|
399
|
+
await this.outbox.put(next);
|
|
400
|
+
return next;
|
|
401
|
+
});
|
|
402
|
+
if (!committed)
|
|
403
|
+
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
404
|
+
this.values.set(record.hash, committed);
|
|
405
|
+
this.emit();
|
|
406
|
+
return committed;
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
this.uploadWaiting--;
|
|
410
|
+
release();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
134
413
|
async save(record) {
|
|
135
|
-
return this.
|
|
414
|
+
return this.withEpoch("rw", async () => {
|
|
136
415
|
const previous = await this.outbox.get([this.scope, record.hash]);
|
|
137
416
|
if (!previous || previous.confirmed || previous.status === "sent")
|
|
138
417
|
return previous;
|
|
418
|
+
// A second document may already have durably replaced the Blob with its URL.
|
|
419
|
+
if (hasVoice(record) && !hasVoice(previous))
|
|
420
|
+
return previous;
|
|
139
421
|
await this.outbox.put(record);
|
|
140
422
|
return record;
|
|
141
423
|
});
|
|
142
424
|
}
|
|
143
425
|
/** Confirm only after the authoritative message has been durably merged.
|
|
144
426
|
* A tombstone prevents another tab's late failure from recreating the outbox. */
|
|
145
|
-
|
|
427
|
+
confirm(hashes) {
|
|
146
428
|
hashes = [...new Set(hashes)];
|
|
147
429
|
if (!hashes.length)
|
|
430
|
+
return Promise.resolve();
|
|
431
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
432
|
+
return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
433
|
+
if (this.confirmationSize + hashes.length > 4096)
|
|
434
|
+
return Promise.reject(new Error("OUTBOX_CONFIRM_BACKPRESSURE"));
|
|
435
|
+
const pending = new Promise((resolve, reject) => this.confirmationQueue.push({ hashes, resolve, reject }));
|
|
436
|
+
this.confirmationSize += hashes.length;
|
|
437
|
+
this.scheduleConfirmations();
|
|
438
|
+
return pending;
|
|
439
|
+
}
|
|
440
|
+
scheduleConfirmations() {
|
|
441
|
+
if (this.confirmationTimer !== undefined || this.confirmationRunning || !this.confirmationQueue.length)
|
|
148
442
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
443
|
+
this.confirmationTimer = setTimeout(() => { this.confirmationTimer = undefined; void this.drainConfirmations(); }, 16);
|
|
444
|
+
}
|
|
445
|
+
drainConfirmations() {
|
|
446
|
+
if (this.confirmationRunning)
|
|
447
|
+
return this.confirmationRunning;
|
|
448
|
+
const batch = this.confirmationQueue.splice(0);
|
|
449
|
+
if (!batch.length)
|
|
450
|
+
return Promise.resolve();
|
|
451
|
+
const size = batch.reduce((sum, item) => sum + item.hashes.length, 0);
|
|
452
|
+
return this.confirmationRunning = (async () => {
|
|
453
|
+
let failed = false, error;
|
|
454
|
+
try {
|
|
455
|
+
await this.confirmNow([...new Set(batch.flatMap(item => item.hashes))]);
|
|
456
|
+
}
|
|
457
|
+
catch (reason) {
|
|
458
|
+
failed = true;
|
|
459
|
+
error = reason;
|
|
460
|
+
}
|
|
461
|
+
this.confirmationSize -= size;
|
|
462
|
+
for (const item of batch)
|
|
463
|
+
if (failed)
|
|
464
|
+
item.reject(error);
|
|
465
|
+
else
|
|
466
|
+
item.resolve();
|
|
467
|
+
})().finally(() => {
|
|
468
|
+
this.confirmationRunning = undefined;
|
|
469
|
+
this.scheduleConfirmations();
|
|
155
470
|
});
|
|
471
|
+
}
|
|
472
|
+
failConfirmations(error) {
|
|
473
|
+
clearTimeout(this.confirmationTimer);
|
|
474
|
+
this.confirmationTimer = undefined;
|
|
475
|
+
for (const item of this.confirmationQueue.splice(0)) {
|
|
476
|
+
this.confirmationSize -= item.hashes.length;
|
|
477
|
+
item.reject(error);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async flushConfirmations() {
|
|
481
|
+
while (this.confirmationRunning || this.confirmationQueue.length) {
|
|
482
|
+
clearTimeout(this.confirmationTimer);
|
|
483
|
+
this.confirmationTimer = undefined;
|
|
484
|
+
await (this.confirmationRunning ?? this.drainConfirmations());
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async confirmNow(hashes) {
|
|
488
|
+
await this.ready;
|
|
156
489
|
let changed = false;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
490
|
+
try {
|
|
491
|
+
for (let offset = 0; offset < hashes.length; offset += 128) {
|
|
492
|
+
const batch = hashes.slice(offset, offset + 128);
|
|
493
|
+
await this.withEpoch("rw", async () => {
|
|
494
|
+
const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
|
|
495
|
+
const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
|
|
496
|
+
if (confirmed.length)
|
|
497
|
+
await this.outbox.bulkPut(confirmed);
|
|
498
|
+
});
|
|
499
|
+
// Retire only committed rows. A later failed transaction leaves the
|
|
500
|
+
// remaining sends retryable, including after a browser restart.
|
|
501
|
+
for (const hash of batch) {
|
|
502
|
+
changed = this.values.delete(hash) || changed;
|
|
503
|
+
this.releaseUrl(hash);
|
|
504
|
+
}
|
|
505
|
+
if (offset + batch.length < hashes.length)
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
// One confirmation operation publishes at most once, also on partial
|
|
511
|
+
// failure so consumers see exactly the progress that was committed.
|
|
512
|
+
if (changed)
|
|
513
|
+
this.emit();
|
|
514
|
+
}
|
|
161
515
|
}
|
|
162
516
|
async resume() {
|
|
163
517
|
await this.ready;
|
|
164
|
-
if (this.closed)
|
|
518
|
+
if (this.closed || this.closing || this.withdrawn)
|
|
165
519
|
return;
|
|
166
520
|
// Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
|
|
167
521
|
for (const record of this.values.values())
|
|
168
522
|
if (record.status === "sending") {
|
|
169
|
-
if (this.closed)
|
|
523
|
+
if (this.closed || this.withdrawn)
|
|
170
524
|
return;
|
|
171
525
|
try {
|
|
172
526
|
await this.retry(record.hash);
|
|
@@ -177,11 +531,31 @@ export class DurableChatSender {
|
|
|
177
531
|
}
|
|
178
532
|
}
|
|
179
533
|
async close() {
|
|
180
|
-
this.
|
|
534
|
+
this.closing = true;
|
|
535
|
+
this.uploads.forEach(controller => controller.abort());
|
|
181
536
|
this.offConnection();
|
|
182
537
|
this.listeners.clear();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
538
|
+
this.channel?.close();
|
|
539
|
+
this.channel = undefined;
|
|
540
|
+
try {
|
|
541
|
+
await this.ready;
|
|
542
|
+
await this.flushConfirmations();
|
|
543
|
+
await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
this.closed = true;
|
|
547
|
+
this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
|
|
548
|
+
this.releaseUrls();
|
|
549
|
+
this.values.clear();
|
|
550
|
+
this.snapshots.clear();
|
|
551
|
+
this.db.close();
|
|
552
|
+
}
|
|
186
553
|
}
|
|
187
554
|
}
|
|
555
|
+
function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
|
|
556
|
+
function withVoiceUrl(message, url) {
|
|
557
|
+
const body = message.body;
|
|
558
|
+
return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
|
|
559
|
+
}
|
|
560
|
+
function hasVoice(record) { return Boolean(record.voiceBlob || record.voiceData); }
|
|
561
|
+
function voiceBlob(record) { return record.voiceBlob ?? new Blob([record.voiceData.bytes], { type: record.voiceData.type }); }
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -29,3 +29,7 @@ export * from "./durableSender.js";
|
|
|
29
29
|
export * from "./chatSession.js";
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
|
+
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
|
|
34
|
+
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
package/lib/runtime/index.js
CHANGED
|
@@ -29,3 +29,7 @@ export * from "./durableSender.js";
|
|
|
29
29
|
export * from "./chatSession.js";
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
|
+
export * from "./conversationRead.js";
|
|
33
|
+
export { projectConversationReading } from "./conversationReading.js";
|
|
34
|
+
export { projectMessageTimeline } from "./messageTimeline.js";
|
|
35
|
+
export * from "./messageLaunch.js";
|
|
@@ -36,6 +36,9 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
36
36
|
private options;
|
|
37
37
|
private storage;
|
|
38
38
|
private watches;
|
|
39
|
+
private windowEdges;
|
|
40
|
+
private accessListeners;
|
|
41
|
+
private allListeners;
|
|
39
42
|
private reads;
|
|
40
43
|
private syncing;
|
|
41
44
|
private paging;
|
|
@@ -53,12 +56,32 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
53
56
|
private queueBytes;
|
|
54
57
|
private pendingBytes;
|
|
55
58
|
private pendingRecords;
|
|
59
|
+
private legacyImports;
|
|
60
|
+
private revokingScope?;
|
|
61
|
+
private scopeReady?;
|
|
62
|
+
private scopeVersion;
|
|
63
|
+
private scopeAllowed;
|
|
64
|
+
private scopeDenied;
|
|
65
|
+
private enqueuing;
|
|
66
|
+
private ensureScope;
|
|
56
67
|
constructor(options: MessageClientOptions<T>);
|
|
68
|
+
/** Shared guard for sibling caches; it never authorizes a denied old instance. */
|
|
69
|
+
subscribeAccessRevoked(listener: () => void): () => void;
|
|
70
|
+
scopeAccess(): Promise<{
|
|
71
|
+
version: number;
|
|
72
|
+
allowed: boolean;
|
|
73
|
+
}>;
|
|
57
74
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
58
75
|
subscribe(thread: string, listener: () => void): () => void;
|
|
59
|
-
|
|
76
|
+
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
77
|
+
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
78
|
+
restore(since: number, threadId?: string): Promise<void>;
|
|
79
|
+
read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
|
|
60
80
|
/** Application connection/wake events invalidate, rather than fetching directly. */
|
|
61
81
|
invalidate(thread: string): void;
|
|
82
|
+
private fallback;
|
|
83
|
+
/** Migration batches remain separate from authoritative message coverage. */
|
|
84
|
+
importLegacy(thread: string, messages: T[]): Promise<void>;
|
|
62
85
|
private reload;
|
|
63
86
|
/** Events are visible only after their atomic batch commits. */
|
|
64
87
|
receive(thread: string, messages: T[]): Promise<void>;
|
|
@@ -67,17 +90,18 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
67
90
|
private release;
|
|
68
91
|
private flushWrites;
|
|
69
92
|
private runWrites;
|
|
93
|
+
private assertNetworkAvailable;
|
|
70
94
|
synchronize(thread: string): Promise<void>;
|
|
95
|
+
/** Keep an explicitly requested history range in the shared subscribed view. */
|
|
96
|
+
expandWindow(thread: string, page: MessageSnapshot<T>): Promise<void>;
|
|
71
97
|
earlier(thread: string, before: {
|
|
72
98
|
timestamp: number;
|
|
73
99
|
id: string;
|
|
74
|
-
}, limit?: number): Promise<
|
|
75
|
-
messages: T[];
|
|
76
|
-
hasEarlier: boolean;
|
|
77
|
-
resetVersion: number;
|
|
78
|
-
complete: boolean;
|
|
79
|
-
}>;
|
|
100
|
+
}, limit?: number, expand?: boolean): Promise<MessageSnapshot<T>>;
|
|
80
101
|
private fillWindow;
|
|
102
|
+
private denyView;
|
|
103
|
+
private denyScope;
|
|
104
|
+
revokeScope(): Promise<number>;
|
|
81
105
|
revoke(thread: string): Promise<void>;
|
|
82
|
-
close(): Promise<void>;
|
|
106
|
+
close(beforeDispose?: Promise<unknown>): Promise<void>;
|
|
83
107
|
}
|