@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.50

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 +77 -13
  9. package/lib/runtime/chatSession.js +483 -42
  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 +54 -14
  19. package/lib/runtime/durableSender.js +354 -44
  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,20 @@ 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
+ version = 0;
18
+ withdrawn = false;
19
+ revoking;
20
+ channel;
21
+ accepted = new Set();
10
22
  scope;
11
23
  identity;
12
24
  values = new Map();
@@ -14,33 +26,197 @@ export class DurableChatSender {
14
26
  listeners = new Set();
15
27
  running = new Map();
16
28
  closed = false;
29
+ closing = false;
30
+ empty = [];
17
31
  offConnection;
18
32
  ready;
19
33
  constructor(client, scope, onError, options = {}) {
20
34
  this.client = client;
21
35
  this.onError = onError;
36
+ this.options = options;
22
37
  this.scope = messageScopeKey(scope);
23
38
  this.identity = { ...scope };
24
39
  this.db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
25
40
  this.db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
41
+ this.db.version(2).stores({ scopeEpochs: "scope" });
42
+ this.db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
26
43
  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
- });
44
+ this.epochs = this.db.table("scopeEpochs");
45
+ this.outbox.hook("creating", (_key, row) => { row.pendingState = row.confirmed ? 0 : 1; });
46
+ this.outbox.hook("updating", (changes, _key, row) => ({ pendingState: (Object.hasOwn(changes, "confirmed") ? changes.confirmed : row.confirmed) ? 0 : 1 }));
47
+ this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
48
+ this.ready = this.restore();
49
+ void this.ready.catch(() => { });
50
+ if (typeof BroadcastChannel !== "undefined") {
51
+ this.channel = new BroadcastChannel(`pmate-chat-outbox:${this.scope}:${this.db.name}`);
52
+ this.channel.onmessage = event => { if (Number.isSafeInteger(event.data?.version) && event.data.version > this.version)
53
+ this.withdraw(); };
54
+ }
33
55
  this.offConnection = client.watchConnection(state => {
34
56
  if (state === "connected")
35
57
  void this.resume().catch(onError);
36
58
  });
37
59
  }
60
+ /** Read only the selected durable rows, without restoring or delivering an outbox. */
61
+ static async readSelected(scope, threadId, ids, version, options = {}) {
62
+ if (ids.length > 100)
63
+ throw new Error("INVALID_OUTBOX_SELECTION");
64
+ const key = messageScopeKey(scope), db = new Dexie(options.name ?? "pmate-chat-outbox", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
65
+ db.version(1).stores({ outbox: "[scope+hash],scope,[scope+threadId]" });
66
+ db.version(2).stores({ scopeEpochs: "scope" });
67
+ db.version(3).stores({ outbox: "[scope+hash],scope,[scope+threadId],[scope+pendingState+hash]" });
68
+ try {
69
+ return await db.transaction("r", db.table("outbox"), db.table("scopeEpochs"), async () => {
70
+ if (((await db.table("scopeEpochs").get(key))?.version ?? 0) !== version)
71
+ return [];
72
+ const rows = await db.table("outbox").bulkGet(ids.map(id => [key, id]));
73
+ return rows.flatMap(row => row && row.scope === key && row.threadId === threadId && !row.confirmed && (row.accessVersion ?? 0) === version ? [row] : []);
74
+ });
75
+ }
76
+ finally {
77
+ db.close();
78
+ }
79
+ }
80
+ async readAccess() {
81
+ const access = await this.options.access?.();
82
+ if (access && (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.allowed !== "boolean"))
83
+ throw new Error("INVALID_OUTBOX_ACCESS");
84
+ return access;
85
+ }
86
+ async restore() {
87
+ const access = await this.readAccess();
88
+ const loaded = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
89
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
90
+ const version = access?.version ?? current;
91
+ if (version < current)
92
+ throw new Error("OUTBOX_SCOPE_CHANGED");
93
+ if (version > current) {
94
+ await this.epochs.put({ scope: this.scope, version });
95
+ await this.deleteRows();
96
+ }
97
+ const total = await this.outbox.where("scope").equals(this.scope).count();
98
+ const indexed = await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, Dexie.minKey], [this.scope, 1, Dexie.maxKey], true, true).count();
99
+ return { version, advanced: version > current, legacy: total !== indexed };
100
+ });
101
+ this.version = loaded.version;
102
+ if (loaded.advanced)
103
+ this.channel?.postMessage({ version: this.version });
104
+ if (this.closed || this.withdrawn)
105
+ return;
106
+ const current = await this.readAccess();
107
+ if (current && (!current.allowed || current.version !== this.version))
108
+ return;
109
+ if (access?.allowed === false)
110
+ return;
111
+ let after;
112
+ const restored = new Map();
113
+ while (!this.closed && !this.withdrawn) {
114
+ const batch = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
115
+ if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
116
+ throw new Error("OUTBOX_SCOPE_CHANGED");
117
+ const keys = await (loaded.legacy
118
+ ? this.outbox.where("[scope+hash]").between([this.scope, after ?? Dexie.minKey], [this.scope, Dexie.maxKey], after === undefined, true)
119
+ : this.outbox.where("[scope+pendingState+hash]").between([this.scope, 1, after ?? Dexie.minKey], [this.scope, 1, Dexie.maxKey], after === undefined, true)).limit(128).primaryKeys();
120
+ if (!keys.length)
121
+ return { last: undefined, records: [] };
122
+ const last = keys.at(-1)[1];
123
+ // Key-only metadata covers confirmed history without reading wire bodies.
124
+ // Missing metadata identifies rows written by an older SDK after upgrade.
125
+ const confirmed = loaded.legacy ? await this.outbox.where("[scope+pendingState+hash]").between([this.scope, 0, keys[0][1]], [this.scope, 0, last], true, true).primaryKeys() : [];
126
+ const confirmedHashes = new Set(confirmed.map(key => key[1]));
127
+ const needed = keys.filter(key => !confirmedHashes.has(key[1]));
128
+ const rows = (await this.outbox.bulkGet(needed)).filter((row) => Boolean(row));
129
+ const repairs = rows.filter(row => row.pendingState !== (row.confirmed ? 0 : 1));
130
+ if (repairs.length)
131
+ await this.outbox.bulkPut(repairs.map(row => ({ ...row, pendingState: row.confirmed ? 0 : 1 })));
132
+ return { last, records: rows.filter(row => !row.confirmed && (row.accessVersion ?? 0) === this.version) };
133
+ });
134
+ for (const row of batch.records)
135
+ restored.set(row.hash, row);
136
+ if (batch.last === undefined)
137
+ break;
138
+ after = batch.last;
139
+ // Leave the database transaction before yielding to input/rendering work.
140
+ await new Promise(resolve => setTimeout(resolve, 0));
141
+ }
142
+ if (this.closed || this.withdrawn)
143
+ return;
144
+ const latest = await this.readAccess();
145
+ if (latest && (!latest.allowed || latest.version !== this.version))
146
+ return;
147
+ this.values = restored;
148
+ this.emit();
149
+ }
150
+ async checkAccess() {
151
+ if (this.closed)
152
+ throw new Error("CHAT_SENDER_CLOSED");
153
+ if (this.withdrawn)
154
+ throw new Error("OUTBOX_ACCESS_REVOKED");
155
+ const access = await this.readAccess();
156
+ if (access && access.version !== this.version) {
157
+ this.withdraw();
158
+ throw new Error("OUTBOX_SCOPE_CHANGED");
159
+ }
160
+ if (access && !access.allowed)
161
+ throw new Error("OUTBOX_ACCESS_REVOKED");
162
+ }
163
+ async withEpoch(mode, operation) {
164
+ await this.checkAccess();
165
+ try {
166
+ return await this.db.transaction(mode, this.outbox, this.epochs, async () => {
167
+ if (this.closed || this.withdrawn)
168
+ throw new Error("OUTBOX_ACCESS_REVOKED");
169
+ if (((await this.epochs.get(this.scope))?.version ?? 0) !== this.version)
170
+ throw new Error("OUTBOX_SCOPE_CHANGED");
171
+ return operation();
172
+ });
173
+ }
174
+ catch (error) {
175
+ if (String(error).includes("OUTBOX_SCOPE_CHANGED"))
176
+ this.withdraw();
177
+ throw error;
178
+ }
179
+ }
180
+ deleteRows() { return this.outbox.where("[scope+hash]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete(); }
181
+ /** Stop disclosure immediately; durable invalidation is coordinated separately. */
182
+ withdraw() {
183
+ this.withdrawn = true;
184
+ this.uploads.forEach(controller => controller.abort());
185
+ this.releaseUrls();
186
+ this.values.clear();
187
+ this.emit();
188
+ }
189
+ revoke(targetVersion) {
190
+ if (this.closed || this.closing)
191
+ return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
192
+ if (this.revoking)
193
+ return this.revoking;
194
+ this.withdraw();
195
+ return this.revoking = (async () => {
196
+ await this.ready.catch(error => { if (!String(error).includes("OUTBOX_SCOPE_CHANGED"))
197
+ throw error; });
198
+ const version = await this.db.transaction("rw", this.outbox, this.epochs, async () => {
199
+ const current = (await this.epochs.get(this.scope))?.version ?? 0;
200
+ const target = targetVersion ?? this.version + 1;
201
+ if (!Number.isSafeInteger(target) || target < 0)
202
+ throw new Error("INVALID_OUTBOX_EPOCH");
203
+ if (current >= target)
204
+ return current;
205
+ await this.epochs.put({ scope: this.scope, version: target });
206
+ await this.deleteRows();
207
+ return target;
208
+ });
209
+ this.channel?.postMessage({ version });
210
+ })();
211
+ }
38
212
  initialize() { return this.ready; }
39
213
  subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
40
214
  snapshot(threadId) {
215
+ if (this.closed || this.closing || this.withdrawn)
216
+ return this.empty;
41
217
  let snapshot = this.snapshots.get(threadId);
42
218
  if (!snapshot) {
43
- snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId);
219
+ snapshot = [...this.values.values()].filter(record => threadId === undefined || record.threadId === threadId).map(record => this.localView(record));
44
220
  this.snapshots.set(threadId, snapshot);
45
221
  }
46
222
  return snapshot;
@@ -50,7 +226,8 @@ export class DurableChatSender {
50
226
  /** Resolves after the exact wire message is durable, independently of network delivery. */
51
227
  async enqueue(threadId, input) {
52
228
  const message = await this.persistOutgoing(threadId, input);
53
- void this.retry(message.hash).catch(error => this.onError(error));
229
+ if (!this.closed && !this.closing && !this.withdrawn)
230
+ void this.retry(message.hash).catch(error => this.onError(error));
54
231
  return message;
55
232
  }
56
233
  /** Compatibility API: resolves only after transport delivery. */
@@ -58,23 +235,32 @@ export class DurableChatSender {
58
235
  const message = await this.persistOutgoing(threadId, input);
59
236
  return this.retry(message.hash);
60
237
  }
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;
238
+ persistOutgoing(threadId, input) {
239
+ if (this.closed || this.closing || this.withdrawn)
240
+ return Promise.reject(new Error("OUTBOX_ACCESS_REVOKED"));
241
+ const pending = (async () => {
242
+ await this.ready;
243
+ await this.checkAccess();
244
+ const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
245
+ if (message.from !== this.identity.profileId)
246
+ throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
247
+ if (input.voiceBlob) {
248
+ const body = message.body;
249
+ if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
250
+ throw new Error("INVALID_VOICE_ATTACHMENT");
251
+ }
252
+ const record = { ...(input.voiceBlob ? { voiceData: { bytes: await input.voiceBlob.arrayBuffer(), type: input.voiceBlob.type } } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
253
+ await this.withEpoch("rw", async () => { await this.outbox.put(record); });
254
+ await this.checkAccess();
255
+ this.values.set(record.hash, record);
256
+ this.emit();
257
+ return message;
258
+ })().finally(() => this.accepted.delete(pending));
259
+ this.accepted.add(pending);
260
+ return pending;
75
261
  }
76
262
  retry(hash) {
77
- if (this.closed)
263
+ if (this.closed || this.closing || this.withdrawn)
78
264
  return Promise.reject(new Error("CHAT_SENDER_CLOSED"));
79
265
  const running = this.running.get(hash);
80
266
  if (running)
@@ -85,8 +271,8 @@ export class DurableChatSender {
85
271
  }
86
272
  async transmit(hash) {
87
273
  await this.ready;
88
- const record = await this.outbox.get([this.scope, hash]);
89
- if (!record || this.closed)
274
+ let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
275
+ if (!record || this.closed || this.withdrawn)
90
276
  throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
91
277
  if (record.status === "sent" || record.confirmed)
92
278
  return record.message;
@@ -96,15 +282,24 @@ export class DurableChatSender {
96
282
  if (!latest || latest.confirmed || latest.status === "sent")
97
283
  return latest?.message ?? record.message;
98
284
  }
285
+ await this.checkAccess();
99
286
  if (this.values.get(hash)?.status !== "sending") {
100
287
  this.values.set(hash, sending);
101
288
  this.emit();
102
289
  }
103
290
  try {
291
+ if (hasVoice(record)) {
292
+ record = await this.prepareVoice(record);
293
+ if (record.confirmed || record.status === "sent")
294
+ return record.message;
295
+ }
296
+ await this.checkAccess();
297
+ if (this.closing)
298
+ throw new Error("CHAT_SENDER_CLOSED");
104
299
  const message = await this.client.sendPrepared(record.message);
105
300
  const sent = { ...record, message, status: "sent" };
106
301
  const committed = await this.save(sent);
107
- if (!this.closed) {
302
+ if (!this.closed && !this.withdrawn) {
108
303
  if (committed?.confirmed || !committed)
109
304
  this.values.delete(hash);
110
305
  else
@@ -114,7 +309,7 @@ export class DurableChatSender {
114
309
  return message;
115
310
  }
116
311
  catch (error) {
117
- if (!this.closed) {
312
+ if (!this.closed && !this.withdrawn) {
118
313
  const failed = { ...record, status: "failed" };
119
314
  const committed = await this.save(failed);
120
315
  if (committed?.confirmed || committed?.status === "sent") {
@@ -131,11 +326,93 @@ export class DurableChatSender {
131
326
  throw error;
132
327
  }
133
328
  }
329
+ releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
330
+ URL.revokeObjectURL(value.url);
331
+ this.localUrls.delete(hash);
332
+ } }
333
+ releaseUrls() { for (const hash of this.localUrls.keys())
334
+ this.releaseUrl(hash); }
335
+ localView(record) {
336
+ if (!hasVoice(record) || typeof URL.createObjectURL !== "function")
337
+ return record;
338
+ let cached = this.localViews.get(record);
339
+ if (cached)
340
+ return cached;
341
+ const blob = voiceBlob(record);
342
+ let local = this.localUrls.get(record.hash);
343
+ if (!local || local.blob !== blob) {
344
+ this.releaseUrl(record.hash);
345
+ local = { blob, url: URL.createObjectURL(blob) };
346
+ this.localUrls.set(record.hash, local);
347
+ }
348
+ cached = { ...record, voiceBlob: blob, message: withVoiceUrl(record.message, local.url) };
349
+ this.localViews.set(record, cached);
350
+ return cached;
351
+ }
352
+ async prepareVoice(record) {
353
+ if (this.uploadWaiting >= 32)
354
+ throw new Error("VOICE_UPLOAD_BACKPRESSURE");
355
+ this.uploadWaiting++;
356
+ const previous = this.uploadTail;
357
+ let release;
358
+ this.uploadTail = new Promise(resolve => { release = resolve; });
359
+ try {
360
+ await previous;
361
+ await this.checkAccess();
362
+ if (this.closing)
363
+ throw new Error("CHAT_SENDER_CLOSED");
364
+ const latest = await this.withEpoch("r", () => this.outbox.get([this.scope, record.hash]));
365
+ if (!latest)
366
+ throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
367
+ if (!hasVoice(latest) || latest.confirmed || latest.status === "sent")
368
+ return latest;
369
+ if (!this.options.uploadVoice)
370
+ throw new Error("VOICE_UPLOAD_UNAVAILABLE");
371
+ const controller = new AbortController();
372
+ this.uploads.add(controller);
373
+ const timeout = setTimeout(() => controller.abort(), 30_000);
374
+ let url;
375
+ try {
376
+ url = await this.options.uploadVoice(voiceBlob(latest), controller.signal);
377
+ }
378
+ finally {
379
+ clearTimeout(timeout);
380
+ this.uploads.delete(controller);
381
+ }
382
+ const parsed = new URL(url);
383
+ if (!["https:", "http:"].includes(parsed.protocol) || parsed.username || parsed.password)
384
+ throw new Error("INVALID_VOICE_UPLOAD_URL");
385
+ await this.checkAccess();
386
+ if (this.closing)
387
+ throw new Error("CHAT_SENDER_CLOSED");
388
+ const { voiceBlob: _blob, voiceData: _data, ...uploaded } = latest;
389
+ const committed = await this.withEpoch("rw", async () => {
390
+ const current = await this.outbox.get([this.scope, record.hash]);
391
+ if (!current || current.confirmed || current.status === "sent" || !hasVoice(current))
392
+ return current;
393
+ const next = { ...uploaded, message: withVoiceUrl(latest.message, url) };
394
+ await this.outbox.put(next);
395
+ return next;
396
+ });
397
+ if (!committed)
398
+ throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
399
+ this.values.set(record.hash, committed);
400
+ this.emit();
401
+ return committed;
402
+ }
403
+ finally {
404
+ this.uploadWaiting--;
405
+ release();
406
+ }
407
+ }
134
408
  async save(record) {
135
- return this.db.transaction("rw", this.outbox, async () => {
409
+ return this.withEpoch("rw", async () => {
136
410
  const previous = await this.outbox.get([this.scope, record.hash]);
137
411
  if (!previous || previous.confirmed || previous.status === "sent")
138
412
  return previous;
413
+ // A second document may already have durably replaced the Blob with its URL.
414
+ if (hasVoice(record) && !hasVoice(previous))
415
+ return previous;
139
416
  await this.outbox.put(record);
140
417
  return record;
141
418
  });
@@ -147,26 +424,41 @@ export class DurableChatSender {
147
424
  if (!hashes.length)
148
425
  return;
149
426
  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);
155
- });
156
427
  let changed = false;
157
- for (const hash of hashes)
158
- changed = this.values.delete(hash) || changed;
159
- if (changed)
160
- this.emit();
428
+ try {
429
+ for (let offset = 0; offset < hashes.length; offset += 128) {
430
+ const batch = hashes.slice(offset, offset + 128);
431
+ await this.withEpoch("rw", async () => {
432
+ const previous = await this.outbox.bulkGet(batch.map(hash => [this.scope, hash]));
433
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, voiceBlob: undefined, voiceData: undefined, status: "sent", confirmed: true }));
434
+ if (confirmed.length)
435
+ await this.outbox.bulkPut(confirmed);
436
+ });
437
+ // Retire only committed rows. A later failed transaction leaves the
438
+ // remaining sends retryable, including after a browser restart.
439
+ for (const hash of batch) {
440
+ changed = this.values.delete(hash) || changed;
441
+ this.releaseUrl(hash);
442
+ }
443
+ if (offset + batch.length < hashes.length)
444
+ await new Promise(resolve => setTimeout(resolve, 0));
445
+ }
446
+ }
447
+ finally {
448
+ // One confirmation operation publishes at most once, also on partial
449
+ // failure so consumers see exactly the progress that was committed.
450
+ if (changed)
451
+ this.emit();
452
+ }
161
453
  }
162
454
  async resume() {
163
455
  await this.ready;
164
- if (this.closed)
456
+ if (this.closed || this.closing || this.withdrawn)
165
457
  return;
166
458
  // Only interrupted, unconfirmed sends resume automatically; failed sends require retry.
167
459
  for (const record of this.values.values())
168
460
  if (record.status === "sending") {
169
- if (this.closed)
461
+ if (this.closed || this.withdrawn)
170
462
  return;
171
463
  try {
172
464
  await this.retry(record.hash);
@@ -177,11 +469,29 @@ export class DurableChatSender {
177
469
  }
178
470
  }
179
471
  async close() {
180
- this.closed = true;
472
+ this.closing = true;
473
+ this.uploads.forEach(controller => controller.abort());
181
474
  this.offConnection();
182
475
  this.listeners.clear();
183
- await this.ready;
184
- await Promise.allSettled([...this.running.values()]);
185
- this.db.close();
476
+ this.channel?.close();
477
+ this.channel = undefined;
478
+ try {
479
+ await this.ready;
480
+ await Promise.allSettled([...this.accepted, ...this.running.values(), ...(this.revoking ? [this.revoking] : [])]);
481
+ }
482
+ finally {
483
+ this.closed = true;
484
+ this.releaseUrls();
485
+ this.values.clear();
486
+ this.snapshots.clear();
487
+ this.db.close();
488
+ }
186
489
  }
187
490
  }
491
+ function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
492
+ function withVoiceUrl(message, url) {
493
+ const body = message.body;
494
+ return { ...message, body: { ...body, content: { ...body.content, voice: { ...body.content.voice, url } } } };
495
+ }
496
+ function hasVoice(record) { return Boolean(record.voiceBlob || record.voiceData); }
497
+ 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
  }