@pmate/chat-sdk 0.12.0-next.6 → 0.12.0-next.60

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 (64) hide show
  1. package/README.md +98 -3
  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 +109 -12
  9. package/lib/runtime/chatSession.js +624 -40
  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 +30 -17
  18. package/lib/runtime/durableSender.d.ts +97 -14
  19. package/lib/runtime/durableSender.js +786 -71
  20. package/lib/runtime/groupClient.d.ts +76 -0
  21. package/lib/runtime/groupClient.js +325 -0
  22. package/lib/runtime/index.d.ts +6 -0
  23. package/lib/runtime/index.js +5 -0
  24. package/lib/runtime/messageClient.d.ts +30 -8
  25. package/lib/runtime/messageClient.js +332 -66
  26. package/lib/runtime/messageControls.d.ts +19 -0
  27. package/lib/runtime/messageControls.js +44 -0
  28. package/lib/runtime/messageLaunch.d.ts +42 -0
  29. package/lib/runtime/messageLaunch.js +133 -0
  30. package/lib/runtime/messageQuotes.d.ts +3 -0
  31. package/lib/runtime/messageQuotes.js +17 -0
  32. package/lib/runtime/messageReaders.d.ts +12 -1
  33. package/lib/runtime/messageReaders.js +67 -3
  34. package/lib/runtime/messageStore.d.ts +38 -2
  35. package/lib/runtime/messageStore.js +215 -11
  36. package/lib/runtime/messageTimeline.d.ts +50 -0
  37. package/lib/runtime/messageTimeline.js +120 -0
  38. package/lib/runtime/outboxSchema.d.ts +5 -0
  39. package/lib/runtime/outboxSchema.js +59 -0
  40. package/lib/runtime/outgoingContent.d.ts +85 -0
  41. package/lib/runtime/outgoingContent.js +106 -0
  42. package/lib/runtime/readBatchJournal.d.ts +35 -0
  43. package/lib/runtime/readBatchJournal.js +210 -0
  44. package/lib/runtime/readCounts.d.ts +15 -1
  45. package/lib/runtime/readCounts.js +96 -8
  46. package/lib/runtime/readIndex.d.ts +68 -7
  47. package/lib/runtime/readIndex.js +460 -22
  48. package/lib/runtime/readSchema.d.ts +2 -0
  49. package/lib/runtime/readSchema.js +58 -0
  50. package/lib/runtime/readSync.d.ts +1 -0
  51. package/lib/runtime/readSync.js +25 -8
  52. package/lib/runtime/readingClient.d.ts +19 -0
  53. package/lib/runtime/readingClient.js +131 -19
  54. package/lib/runtime/readingContent.d.ts +5 -0
  55. package/lib/runtime/readingContent.js +23 -0
  56. package/lib/runtime/recordCache.d.ts +9 -0
  57. package/lib/runtime/recordCache.js +62 -5
  58. package/lib/runtime/responseCache.d.ts +57 -0
  59. package/lib/runtime/responseCache.js +230 -0
  60. package/lib/runtime/socialReadClient.d.ts +3 -1
  61. package/lib/runtime/socialReadClient.js +8 -4
  62. package/lib/runtime/voiceUpload.d.ts +2 -0
  63. package/lib/runtime/voiceUpload.js +27 -0
  64. package/package.json +6 -1
@@ -1,3 +1,4 @@
1
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
1
2
  import { WindowedTasks } from "./coalescer.js";
2
3
  import { IndexedDbMessageStore, messageScopeKey, mergeStoredMessage } from "./messageStore.js";
3
4
  /** Local-first message facade; hosts never select a cache or network source. */
@@ -5,9 +6,12 @@ export class MessageClient {
5
6
  options;
6
7
  storage;
7
8
  watches = new Map();
9
+ windowEdges = new Map();
10
+ accessListeners = new Set();
8
11
  allListeners = new Set();
9
12
  reads = new Map();
10
13
  syncing = new Map();
14
+ completedSyncs = new Map();
11
15
  paging = new Map();
12
16
  writes = new Map();
13
17
  activeWrites = new Set();
@@ -23,6 +27,16 @@ export class MessageClient {
23
27
  queueBytes;
24
28
  pendingBytes = 0;
25
29
  pendingRecords = 0;
30
+ legacyImports = 0;
31
+ revokingScope;
32
+ scopeReady;
33
+ scopeVersion = 0;
34
+ scopeAllowed = true;
35
+ scopeDenied = false;
36
+ enqueuing = new Set();
37
+ ensureScope() {
38
+ return this.scopeReady ??= this.storage.scopeAccess().then(access => { this.scopeVersion = access.version; this.scopeAllowed = !access.revoked && !this.scopeDenied; });
39
+ }
26
40
  constructor(options) {
27
41
  this.options = options;
28
42
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
@@ -37,29 +51,66 @@ export class MessageClient {
37
51
  this.invalidate(thread);
38
52
  else
39
53
  options.onError?.(error, thread); });
40
- this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
54
+ this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => { if (!isOfflineNetworkFailure(error))
55
+ options.onError?.(error, thread); });
41
56
  if (typeof BroadcastChannel !== "undefined") {
42
57
  this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
43
58
  this.channel.onmessage = event => {
44
59
  if (this.closed)
45
60
  return;
46
- if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
61
+ if (event.data?.type === "scope-revoked" && Number.isSafeInteger(event.data.version)) {
62
+ const version = event.data.version;
63
+ void this.ensureScope().then(async () => {
64
+ const current = await this.storage.scopeAccess();
65
+ if (!this.closed && (this.scopeVersion < version || current.revoked)) {
66
+ this.denyScope();
67
+ options.onError?.(accessRevoked(), "");
68
+ }
69
+ }).catch(error => options.onError?.(error, ""));
70
+ }
71
+ else if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
47
72
  const denied = event.data;
48
73
  void (async () => {
49
74
  const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
75
+ if ((current?.scopeVersion ?? 0) !== (denied.scopeVersion ?? 0))
76
+ return;
50
77
  if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
51
78
  return;
52
79
  this.denyView(denied.thread, denied.version);
53
- options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
80
+ options.onError?.(accessRevoked(), denied.thread);
54
81
  })();
55
82
  }
83
+ else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
84
+ const thread = event.data.thread;
85
+ void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
86
+ options.onError?.(error, thread); });
87
+ }
56
88
  else if (typeof event.data === "string" && this.watches.has(event.data))
57
89
  void this.reload(event.data).catch(error => { if (!this.closed)
58
90
  options.onError?.(error, event.data); });
59
91
  };
60
92
  }
61
93
  }
94
+ /** Shared guard for sibling caches; it never authorizes a denied old instance. */
95
+ subscribeAccessRevoked(listener) { this.accessListeners.add(listener); return () => { this.accessListeners.delete(listener); }; }
96
+ async scopeAccess() {
97
+ await this.ensureScope();
98
+ if (this.closed)
99
+ return { version: this.scopeVersion, allowed: false };
100
+ const current = await this.storage.scopeAccess();
101
+ return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
102
+ }
62
103
  getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
104
+ /** Exact authoritative row only; migration-only rows never prove delivery. */
105
+ async cachedMessage(thread, id) {
106
+ await this.ensureScope();
107
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
108
+ throw accessRevoked();
109
+ const page = await this.storage.readSelected(thread, [id]);
110
+ if (page.coverage?.revoked || (page.coverage?.scopeVersion ?? 0) !== this.scopeVersion)
111
+ throw accessRevoked();
112
+ return page.messages[0];
113
+ }
63
114
  subscribe(thread, listener) {
64
115
  let watch = this.watches.get(thread);
65
116
  if (!watch) {
@@ -71,16 +122,18 @@ export class MessageClient {
71
122
  }
72
123
  subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
73
124
  /** Close the connection gap for saved windows with four bounded background lanes. */
74
- async restore(since) {
75
- const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked);
125
+ async restore(since, threadId) {
126
+ if (this.closed || this.closing || this.scopeDenied || isOffline())
127
+ return;
128
+ const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
76
129
  await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
77
- while (queue.length && !this.closed && !this.closing) {
130
+ while (queue.length && !this.closed && !this.closing && !this.scopeDenied && !isOffline()) {
78
131
  const window = queue.shift();
79
132
  try {
80
133
  await this.synchronize(window.thread);
81
134
  }
82
135
  catch (error) {
83
- if (!this.closed && !this.closing)
136
+ if (!this.closed && !this.closing && !isOfflineNetworkFailure(error))
84
137
  this.options.onError?.(error, window.thread);
85
138
  }
86
139
  }
@@ -95,11 +148,22 @@ export class MessageClient {
95
148
  if (running)
96
149
  return running;
97
150
  const task = (async () => {
151
+ const completedAtStart = this.completedSyncs.get(thread);
152
+ await this.ensureScope();
153
+ if (this.scopeDenied)
154
+ throw accessRevoked();
98
155
  const local = await this.storage.read(thread, limit);
156
+ if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
157
+ this.denyScope();
158
+ throw new Error("MESSAGE_SCOPE_CHANGED");
159
+ }
99
160
  if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
100
161
  if (typeof navigator !== "undefined" && navigator.onLine === false) {
101
- if (!refresh && local.messages.length && !local.coverage?.revoked)
102
- return this.reload(thread);
162
+ if (!refresh && !local.coverage?.revoked) {
163
+ const fallback = await this.fallback(thread, limit);
164
+ if (fallback.messages.length)
165
+ return fallback;
166
+ }
103
167
  throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
104
168
  }
105
169
  this.revoked.delete(thread);
@@ -107,10 +171,18 @@ export class MessageClient {
107
171
  await this.synchronize(thread);
108
172
  }
109
173
  catch (error) {
110
- if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
174
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
175
+ this.denyScope();
111
176
  throw error;
112
- this.options.onError?.(error, thread);
113
- return this.reload(thread);
177
+ }
178
+ if (refresh || [401, 403].includes(error?.status ?? 0))
179
+ throw error;
180
+ const fallback = await this.fallback(thread, limit);
181
+ if (!fallback.messages.length)
182
+ throw error;
183
+ if (!isOfflineNetworkFailure(error))
184
+ this.options.onError?.(error, thread);
185
+ return fallback;
114
186
  }
115
187
  }
116
188
  if (this.closed)
@@ -120,17 +192,24 @@ export class MessageClient {
120
192
  result = await this.fillWindow(thread, limit);
121
193
  }
122
194
  catch (error) {
195
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
196
+ this.denyScope();
197
+ throw error;
198
+ }
123
199
  if ([401, 403].includes(error?.status ?? 0))
124
200
  throw error;
125
- const cached = await this.storage.read(thread, limit);
201
+ const cached = await this.fallback(thread, limit);
126
202
  if (!cached.messages.length)
127
203
  throw error;
128
- this.options.onError?.(error, thread);
129
- await this.reload(thread);
130
- return { messages: cached.messages, hasEarlier: true, complete: false, resetVersion: cached.coverage?.resetVersion ?? 0 };
204
+ if (!isOfflineNetworkFailure(error))
205
+ this.options.onError?.(error, thread);
206
+ return cached;
131
207
  }
132
208
  const snapshot = await this.reload(thread);
209
+ if (this.scopeDenied || this.revoked.has(thread))
210
+ throw accessRevoked();
133
211
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
212
+ && !this.syncing.has(thread) && this.completedSyncs.get(thread) === completedAtStart
134
213
  && (typeof navigator === "undefined" || navigator.onLine !== false))
135
214
  this.invalidate(thread);
136
215
  return limit === 100 ? snapshot : result;
@@ -141,15 +220,86 @@ export class MessageClient {
141
220
  }
142
221
  /** Application connection/wake events invalidate, rather than fetching directly. */
143
222
  invalidate(thread) {
144
- if (!this.closed && !this.closing && !this.revoked.has(thread))
145
- this.syncTasks.schedule(thread, () => this.synchronize(thread));
223
+ if (!this.closed && !this.closing && !this.scopeDenied && !isOffline() && !this.revoked.has(thread))
224
+ this.syncTasks.schedule(thread, async () => {
225
+ if (!isOffline())
226
+ await this.synchronize(thread);
227
+ });
146
228
  }
147
- async reload(thread) {
229
+ async fallback(thread, limit, before) {
230
+ await this.ensureScope();
231
+ if (this.scopeDenied || this.revoked.has(thread))
232
+ throw accessRevoked();
233
+ const page = await this.storage.readFallback(thread, limit, before);
234
+ if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
235
+ this.denyScope();
236
+ throw new Error("MESSAGE_SCOPE_CHANGED");
237
+ }
238
+ if (!before && page.messages.length)
239
+ await this.reload(thread, true);
240
+ return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
241
+ }
242
+ /** Migration batches remain separate from authoritative message coverage. */
243
+ importLegacy(thread, messages) {
244
+ if (this.closed || this.closing)
245
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
246
+ if (messages.length > 128)
247
+ return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
248
+ if (this.legacyImports >= 4)
249
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
250
+ let records;
251
+ try {
252
+ let bytes = 0;
253
+ for (const message of messages) {
254
+ bytes += this.messageBytes(message);
255
+ if (bytes > Math.min(this.transactionBytes, 1_048_576))
256
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
257
+ }
258
+ records = structuredClone(messages);
259
+ }
260
+ catch (error) {
261
+ return Promise.reject(error);
262
+ }
263
+ this.legacyImports++;
264
+ const task = this.storage.importLegacy(thread, records).then(async () => {
265
+ const page = await this.storage.read(thread, 1);
266
+ await this.reload(thread, !page.coverage?.complete);
267
+ this.channel?.postMessage({ type: "legacy", thread });
268
+ }).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
269
+ this.activeWrites.add(task);
270
+ return task;
271
+ }
272
+ async reload(thread, fallback = false) {
273
+ await this.ensureScope();
148
274
  const limit = 100;
149
- const page = await this.storage.read(thread, limit);
275
+ let page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
276
+ const edge = this.windowEdges.get(thread);
277
+ if (edge && edge.version !== (page.coverage?.resetVersion ?? 0))
278
+ this.windowEdges.delete(thread);
279
+ if (edge && edge.version === (page.coverage?.resetVersion ?? 0)) {
280
+ const pages = [page.messages];
281
+ const beforeEdge = (message) => message.timestamp < edge.timestamp || message.timestamp === edge.timestamp && message.id < edge.id;
282
+ let oldest = page.messages[0];
283
+ while (oldest && !beforeEdge(oldest) && (oldest.timestamp !== edge.timestamp || oldest.id !== edge.id) && page.hasEarlier) {
284
+ const next = await (fallback ? this.storage.readFallback(thread, limit, { timestamp: oldest.timestamp, id: oldest.id }) : this.storage.read(thread, limit, { timestamp: oldest.timestamp, id: oldest.id }));
285
+ if ((next.coverage?.resetVersion ?? 0) !== edge.version || (next.coverage?.scopeVersion ?? 0) !== (page.coverage?.scopeVersion ?? 0)) {
286
+ this.windowEdges.delete(thread);
287
+ return this.reload(thread, fallback);
288
+ }
289
+ pages.push(next.messages);
290
+ page = next;
291
+ oldest = next.messages[0];
292
+ }
293
+ const messages = pages.reverse().flat();
294
+ page = { ...page, messages: messages.filter(message => !beforeEdge(message)), hasEarlier: page.hasEarlier || messages.some(beforeEdge) };
295
+ }
150
296
  if (this.closed)
151
297
  throw new Error("MESSAGE_CLIENT_CLOSED");
152
- if (this.revoked.has(thread)) {
298
+ if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
299
+ this.denyScope();
300
+ throw new Error("MESSAGE_SCOPE_CHANGED");
301
+ }
302
+ if (this.scopeDenied || this.revoked.has(thread)) {
153
303
  page.messages = [];
154
304
  page.hasEarlier = false;
155
305
  }
@@ -160,7 +310,7 @@ export class MessageClient {
160
310
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
161
311
  });
162
312
  const old = watch?.snapshot;
163
- const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !this.revoked.has(thread) && (page.coverage?.complete ?? false);
313
+ const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.scopeDenied && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
164
314
  const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
165
315
  if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
166
316
  return old;
@@ -179,8 +329,7 @@ export class MessageClient {
179
329
  if (this.closed || this.closing)
180
330
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
181
331
  if (this.revoked.has(thread))
182
- return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
183
- const queue = this.writes.get(thread) ?? [];
332
+ return Promise.reject(accessRevoked());
184
333
  if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
185
334
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
186
335
  let bytes = 0;
@@ -197,15 +346,25 @@ export class MessageClient {
197
346
  }
198
347
  this.pendingBytes += bytes;
199
348
  this.pendingRecords += messages.length;
200
- const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, resolve, reject }));
201
- this.writes.set(thread, queue);
202
- this.writeTasks.schedule(thread, () => this.flushWrites(thread));
203
- return promise;
349
+ let resolve, reject;
350
+ const committed = new Promise((yes, no) => { resolve = yes; reject = no; });
351
+ const enqueue = this.ensureScope().then(() => {
352
+ if (this.closed || this.scopeDenied)
353
+ throw accessRevoked();
354
+ if (!this.scopeAllowed)
355
+ throw new Error("MESSAGE_SCOPE_NOT_READY");
356
+ const queue = this.writes.get(thread) ?? [];
357
+ queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, scopeVersion: this.scopeVersion, resolve, reject });
358
+ this.writes.set(thread, queue);
359
+ this.writeTasks.schedule(thread, () => this.flushWrites(thread));
360
+ }).catch(error => { this.pendingBytes -= bytes; this.pendingRecords -= messages.length; reject(error); }).finally(() => this.enqueuing.delete(enqueue));
361
+ this.enqueuing.add(enqueue);
362
+ return committed;
204
363
  }
205
- async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
364
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion, expectedScopeVersion) {
206
365
  const initial = (await this.storage.read(thread, 1)).coverage;
207
366
  const version = expectedVersion ?? initial?.resetVersion ?? 0;
208
- const guard = { version, authoritative: complete === true };
367
+ const guard = { version, scopeVersion: expectedScopeVersion ?? this.scopeVersion, authoritative: complete === true };
209
368
  let changed = 0;
210
369
  let navigationChanged = false;
211
370
  // Coverage advances only after all bounded batches have durably committed.
@@ -213,7 +372,7 @@ export class MessageClient {
213
372
  const commit = async () => {
214
373
  if (!batch.length)
215
374
  return;
216
- if (this.closed || this.revoked.has(thread))
375
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
217
376
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
218
377
  changed += await this.storage.commit(thread, batch, undefined, guard);
219
378
  batch = [];
@@ -229,11 +388,13 @@ export class MessageClient {
229
388
  await commit();
230
389
  if (complete !== undefined || checkpoint !== undefined) {
231
390
  const old = (await this.storage.read(thread, 1)).coverage;
232
- if (this.closed || this.revoked.has(thread))
391
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
233
392
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
234
393
  navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
235
394
  await this.storage.commit(thread, [], { resetVersion: version, revoked: complete === true ? false : old?.revoked, syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore }, guard);
236
395
  }
396
+ if (complete === true)
397
+ this.scopeAllowed = true;
237
398
  if (changed || navigationChanged) {
238
399
  await this.reload(thread);
239
400
  this.channel?.postMessage(thread);
@@ -264,14 +425,14 @@ export class MessageClient {
264
425
  try {
265
426
  const groups = new Map();
266
427
  for (const write of queue) {
267
- const group = groups.get(write.version) ?? [];
428
+ const key = `${write.version}:${write.scopeVersion}`, group = groups.get(key) ?? [];
268
429
  group.push(write);
269
- groups.set(write.version, group);
430
+ groups.set(key, group);
270
431
  }
271
432
  let failed = false, failure;
272
- for (const [version, writes] of groups) {
433
+ for (const writes of groups.values()) {
273
434
  try {
274
- await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
435
+ await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, writes[0].version, writes[0].scopeVersion);
275
436
  writes.forEach(write => write.resolve());
276
437
  }
277
438
  catch (error) {
@@ -287,40 +448,58 @@ export class MessageClient {
287
448
  this.release(queue);
288
449
  }
289
450
  }
451
+ assertNetworkAvailable(thread) {
452
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
453
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
454
+ if (isOffline())
455
+ throw new TypeError("MESSAGE_OFFLINE_UNAVAILABLE");
456
+ }
290
457
  synchronize(thread) {
291
- if (this.closed || this.closing || this.revoked.has(thread))
458
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
292
459
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
293
460
  const running = this.syncing.get(thread);
294
461
  if (running)
295
462
  return running;
296
463
  const task = (async () => {
464
+ await this.ensureScope();
297
465
  const coverage = (await this.storage.read(thread, 1)).coverage;
466
+ const scopeVersion = coverage?.scopeVersion ?? 0;
467
+ if (scopeVersion !== this.scopeVersion) {
468
+ this.denyScope();
469
+ throw new Error("MESSAGE_SCOPE_CHANGED");
470
+ }
298
471
  let version = coverage?.resetVersion ?? 0;
299
472
  let after = coverage?.complete ? coverage.checkpoint : undefined;
473
+ this.assertNetworkAvailable(thread);
300
474
  let page = after && this.options.transport.changes
301
475
  ? await this.options.transport.changes(thread, after, this.abort.signal)
302
476
  : await this.options.transport.load(thread, this.abort.signal);
303
477
  if (page.resetRequired) {
304
- if (this.closed || this.revoked.has(thread))
478
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
305
479
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
306
- version = await this.storage.resetThread(thread);
480
+ version = await this.storage.resetThread(thread, false, scopeVersion);
307
481
  after = undefined;
482
+ this.assertNetworkAvailable(thread);
308
483
  page = await this.options.transport.load(thread, this.abort.signal);
309
484
  }
310
485
  for (;;) {
311
- if (this.closed || this.revoked.has(thread))
312
- throw new Error("MESSAGE_ACCESS_REVOKED");
486
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
487
+ throw accessRevoked();
313
488
  if (page.resetRequired)
314
489
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
315
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
490
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
316
491
  if (!page.hasMoreChanges)
317
492
  break;
318
493
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
319
494
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
320
495
  after = page.checkpoint;
496
+ this.assertNetworkAvailable(thread);
321
497
  page = await this.options.transport.changes(thread, after, this.abort.signal);
322
498
  }
499
+ this.completedSyncs.set(thread, (this.completedSyncs.get(thread) ?? 0) + 1);
323
500
  })().catch(async (error) => {
501
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
502
+ this.denyScope();
324
503
  if (!this.closed && !this.closing && error?.status === 403) {
325
504
  try {
326
505
  await this.revoke(thread);
@@ -335,14 +514,51 @@ export class MessageClient {
335
514
  this.syncing.set(thread, task);
336
515
  return task;
337
516
  }
338
- async earlier(thread, before, limit = 100) {
517
+ /** Keep an explicitly requested history range in the shared subscribed view. */
518
+ async expandWindow(thread, page) {
519
+ const first = page.messages[0];
520
+ if (!first || !page.complete)
521
+ return;
522
+ const old = this.windowEdges.get(thread);
523
+ if (!old || old.version !== page.resetVersion || first.timestamp < old.timestamp || first.timestamp === old.timestamp && first.id < old.id)
524
+ this.windowEdges.set(thread, { timestamp: first.timestamp, id: first.id, version: page.resetVersion });
525
+ const snapshot = await this.reload(thread);
526
+ if (snapshot.resetVersion !== page.resetVersion)
527
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
528
+ }
529
+ async earlier(thread, before, limit = 100, expand = false) {
530
+ await this.ensureScope();
531
+ if (this.scopeDenied)
532
+ throw accessRevoked();
339
533
  if (this.closed)
340
534
  throw new Error("MESSAGE_CLIENT_CLOSED");
341
535
  if (this.revoked.has(thread))
342
- throw new Error("MESSAGE_ACCESS_REVOKED");
343
- if (!(await this.storage.read(thread, 1)).coverage?.complete)
344
- await this.synchronize(thread);
345
- return this.fillWindow(thread, limit, before);
536
+ throw accessRevoked();
537
+ try {
538
+ if (!(await this.storage.read(thread, 1)).coverage?.complete) {
539
+ if (typeof navigator !== "undefined" && navigator.onLine === false)
540
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
541
+ await this.synchronize(thread);
542
+ }
543
+ const page = await this.fillWindow(thread, limit, before);
544
+ if (expand)
545
+ await this.expandWindow(thread, page);
546
+ return page;
547
+ }
548
+ catch (error) {
549
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
550
+ this.denyScope();
551
+ throw error;
552
+ }
553
+ if ([401, 403].includes(error?.status ?? 0))
554
+ throw error;
555
+ const fallback = await this.fallback(thread, limit, before);
556
+ if (!fallback.messages.length)
557
+ throw error;
558
+ if (!isOfflineNetworkFailure(error))
559
+ this.options.onError?.(error, thread);
560
+ return fallback;
561
+ }
346
562
  }
347
563
  async fillWindow(thread, limit, before) {
348
564
  let page = await this.storage.read(thread, limit, before);
@@ -354,21 +570,22 @@ export class MessageClient {
354
570
  page = await this.storage.read(thread, limit, before);
355
571
  continue;
356
572
  }
357
- const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
573
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
358
574
  const task = (async () => {
575
+ this.assertNetworkAvailable(thread);
359
576
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
360
- if (this.closed || this.revoked.has(thread))
577
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
361
578
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
362
579
  if (remote.resetRequired) {
363
580
  if (++resets > 2)
364
581
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
365
- await this.storage.resetThread(thread);
582
+ await this.storage.resetThread(thread, false, scopeVersion);
366
583
  await this.synchronize(thread);
367
584
  return;
368
585
  }
369
586
  if (remote.nextBefore === token)
370
587
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
371
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
588
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version, scopeVersion);
372
589
  })().catch(async (error) => {
373
590
  if (!this.closed && !this.closing && error?.status === 403) {
374
591
  try {
@@ -385,9 +602,12 @@ export class MessageClient {
385
602
  await task;
386
603
  page = await this.storage.read(thread, limit, before);
387
604
  }
605
+ if (this.scopeDenied || this.revoked.has(thread))
606
+ throw accessRevoked();
388
607
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
389
608
  }
390
609
  denyView(thread, version) {
610
+ this.windowEdges.delete(thread);
391
611
  this.revoked.add(thread);
392
612
  const watch = this.watches.get(thread);
393
613
  const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
@@ -401,34 +621,80 @@ export class MessageClient {
401
621
  this.watches.set(thread, { snapshot, listeners: new Set() });
402
622
  this.allListeners.forEach(listener => listener(thread));
403
623
  }
624
+ denyScope() {
625
+ const notify = !this.scopeDenied;
626
+ this.scopeDenied = true;
627
+ this.scopeAllowed = false;
628
+ this.abort.abort();
629
+ this.syncTasks.close();
630
+ this.writeTasks.close();
631
+ for (const thread of this.watches.keys())
632
+ this.denyView(thread);
633
+ for (const queue of this.writes.values()) {
634
+ queue.forEach(write => write.reject(accessRevoked()));
635
+ this.release(queue);
636
+ }
637
+ this.writes.clear();
638
+ if (notify)
639
+ this.accessListeners.forEach(listener => listener());
640
+ }
641
+ revokeScope() {
642
+ if (this.closed || this.closing)
643
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
644
+ return this.revokingScope ??= (async () => {
645
+ this.denyScope();
646
+ try {
647
+ await this.ensureScope();
648
+ return await this.storage.revokeScope(this.scopeVersion);
649
+ }
650
+ finally {
651
+ const access = await this.storage.scopeAccess();
652
+ this.channel?.postMessage({ type: "scope-revoked", version: access.version });
653
+ }
654
+ })();
655
+ }
404
656
  async revoke(thread) {
405
657
  this.denyView(thread);
406
658
  const queue = this.writes.get(thread);
407
659
  this.writes.delete(thread);
408
660
  if (queue) {
409
- queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
661
+ queue.forEach(write => write.reject(accessRevoked()));
410
662
  this.release(queue);
411
663
  }
412
664
  try {
413
- await this.storage.resetThread(thread, true);
665
+ await this.storage.resetThread(thread, true, this.scopeVersion);
414
666
  await this.reload(thread);
415
667
  }
416
668
  finally {
417
- this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
669
+ this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion, scopeVersion: this.scopeVersion });
418
670
  }
419
671
  }
420
- async close() {
421
- // Flush accepted event batches before closing; new requests are rejected afterwards.
672
+ async close(beforeDispose) {
673
+ if (this.closed)
674
+ return;
422
675
  this.closing = true;
423
- this.syncTasks.close();
424
- this.writeTasks.close();
425
- await Promise.all([...this.writes.keys()].map(thread => this.flushWrites(thread)));
426
- await Promise.all([...this.activeWrites]);
427
- this.closed = true;
428
- this.abort.abort();
429
- this.channel?.close();
430
- this.watches.clear();
431
- this.allListeners.clear();
432
- this.storage.close();
676
+ try {
677
+ await Promise.all([...this.enqueuing]);
678
+ this.syncTasks.close();
679
+ this.writeTasks.close();
680
+ const flushes = [...this.writes.keys()].map(thread => this.flushWrites(thread));
681
+ const results = await Promise.allSettled([...flushes, ...this.activeWrites, ...(this.revokingScope ? [this.revokingScope] : [])]);
682
+ const failed = results.find((result) => result.status === "rejected");
683
+ if (failed)
684
+ throw failed.reason;
685
+ await beforeDispose;
686
+ }
687
+ finally {
688
+ this.closed = true;
689
+ this.abort.abort();
690
+ this.channel?.close();
691
+ this.watches.clear();
692
+ this.windowEdges.clear();
693
+ this.completedSyncs.clear();
694
+ this.allListeners.clear();
695
+ this.accessListeners.clear();
696
+ this.storage.close();
697
+ }
433
698
  }
434
699
  }
700
+ function accessRevoked() { return Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }); }
@@ -0,0 +1,19 @@
1
+ import type { StoredMessage, MessageScope } from "./messageStore.js";
2
+ export type MessageControl = {
3
+ scope: string;
4
+ thread: string;
5
+ target: string;
6
+ kind: "recall" | "reaction";
7
+ actor: string;
8
+ emoji: string;
9
+ id: string;
10
+ timestamp: number;
11
+ revision: number;
12
+ active: boolean;
13
+ };
14
+ export type ControlKey = [string, string, string, string, string, string];
15
+ export declare const controlKey: (event: MessageControl) => ControlKey;
16
+ export declare function newerControl(old: MessageControl | undefined, next: MessageControl): MessageControl;
17
+ /** Body-free proofs retained independently of the visible history window. */
18
+ export declare function messageControls(message: StoredMessage, scope: MessageScope, key: string): MessageControl[];
19
+ export declare function applyMessageControls<T extends StoredMessage>(messages: T[], controls: readonly MessageControl[]): T[];