@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.
Files changed (52) hide show
  1. package/README.md +34 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  3. package/lib/runtime/batchedReadIndex.js +50 -4
  4. package/lib/runtime/cachedMessage.d.ts +12 -0
  5. package/lib/runtime/cachedMessage.js +18 -0
  6. package/lib/runtime/chatClient.d.ts +1 -0
  7. package/lib/runtime/chatClient.js +1 -1
  8. package/lib/runtime/chatSession.d.ts +78 -13
  9. package/lib/runtime/chatSession.js +494 -43
  10. package/lib/runtime/connectivity.d.ts +4 -0
  11. package/lib/runtime/connectivity.js +8 -0
  12. package/lib/runtime/conversationRead.d.ts +13 -0
  13. package/lib/runtime/conversationRead.js +34 -0
  14. package/lib/runtime/conversationReading.d.ts +28 -0
  15. package/lib/runtime/conversationReading.js +47 -0
  16. package/lib/runtime/createChat.d.ts +5 -5
  17. package/lib/runtime/createChat.js +16 -17
  18. package/lib/runtime/durableSender.d.ts +63 -14
  19. package/lib/runtime/durableSender.js +419 -45
  20. package/lib/runtime/index.d.ts +4 -0
  21. package/lib/runtime/index.js +4 -0
  22. package/lib/runtime/messageClient.d.ts +32 -8
  23. package/lib/runtime/messageClient.js +415 -75
  24. package/lib/runtime/messageControls.d.ts +19 -0
  25. package/lib/runtime/messageControls.js +44 -0
  26. package/lib/runtime/messageLaunch.d.ts +42 -0
  27. package/lib/runtime/messageLaunch.js +133 -0
  28. package/lib/runtime/messageQuotes.d.ts +3 -0
  29. package/lib/runtime/messageQuotes.js +17 -0
  30. package/lib/runtime/messageReaders.d.ts +12 -1
  31. package/lib/runtime/messageReaders.js +67 -3
  32. package/lib/runtime/messageStore.d.ts +40 -2
  33. package/lib/runtime/messageStore.js +218 -10
  34. package/lib/runtime/messageTimeline.d.ts +50 -0
  35. package/lib/runtime/messageTimeline.js +120 -0
  36. package/lib/runtime/readCounts.d.ts +15 -1
  37. package/lib/runtime/readCounts.js +96 -8
  38. package/lib/runtime/readIndex.d.ts +55 -6
  39. package/lib/runtime/readIndex.js +363 -14
  40. package/lib/runtime/readSync.d.ts +1 -0
  41. package/lib/runtime/readSync.js +25 -8
  42. package/lib/runtime/readingClient.d.ts +19 -0
  43. package/lib/runtime/readingClient.js +131 -19
  44. package/lib/runtime/readingContent.d.ts +5 -0
  45. package/lib/runtime/readingContent.js +23 -0
  46. package/lib/runtime/recordCache.d.ts +9 -0
  47. package/lib/runtime/recordCache.js +62 -5
  48. package/lib/runtime/responseCache.d.ts +44 -0
  49. package/lib/runtime/responseCache.js +173 -0
  50. package/lib/runtime/voiceUpload.d.ts +2 -0
  51. package/lib/runtime/voiceUpload.js +20 -0
  52. 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.ready = this.outbox.where("scope").equals(this.scope).toArray().then(records => {
28
- if (this.closed)
29
- return;
30
- records.filter(record => !record.confirmed).forEach(record => this.values.set(record.hash, record));
31
- this.emit();
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
- void this.retry(message.hash).catch(error => this.onError(error));
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
- async persistOutgoing(threadId, input) {
62
- await this.ready;
63
- if (this.closed)
64
- throw new Error("CHAT_SENDER_CLOSED");
65
- const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
66
- if (message.from !== this.identity.profileId)
67
- throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
68
- const record = { scope: this.scope, threadId, hash: message.hash, message, status: "sending" };
69
- await this.outbox.put(record);
70
- if (this.closed)
71
- throw new Error("CHAT_SENDER_CLOSED");
72
- this.values.set(record.hash, record);
73
- this.emit();
74
- return message;
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
- const record = await this.outbox.get([this.scope, hash]);
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.db.transaction("rw", this.outbox, async () => {
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
- async confirm(hashes) {
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
- await this.ready;
150
- await this.db.transaction("rw", this.outbox, async () => {
151
- const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
152
- const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
153
- if (confirmed.length)
154
- await this.outbox.bulkPut(confirmed);
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
- for (const hash of hashes)
158
- changed = this.values.delete(hash) || changed;
159
- if (changed)
160
- this.emit();
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.closed = true;
534
+ this.closing = true;
535
+ this.uploads.forEach(controller => controller.abort());
181
536
  this.offConnection();
182
537
  this.listeners.clear();
183
- await this.ready;
184
- await Promise.allSettled([...this.running.values()]);
185
- this.db.close();
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 }); }
@@ -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";
@@ -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
- read(thread: string, limit?: number): Promise<MessageSnapshot<T>>;
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
  }