@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +152 -81
  2. package/lib/advanced.d.ts +10 -0
  3. package/lib/advanced.js +10 -0
  4. package/lib/chat.d.ts +5 -0
  5. package/lib/chat.js +11 -0
  6. package/lib/chatContract.d.ts +148 -0
  7. package/lib/chatContract.js +1 -0
  8. package/lib/chatError.d.ts +11 -0
  9. package/lib/chatError.js +13 -0
  10. package/lib/chatTypes.d.ts +121 -0
  11. package/lib/chatTypes.js +1 -0
  12. package/lib/credentials.d.ts +26 -0
  13. package/lib/credentials.js +110 -0
  14. package/lib/fileMetadata.d.ts +9 -0
  15. package/lib/fileMetadata.js +18 -0
  16. package/lib/framework.d.ts +11 -0
  17. package/lib/framework.js +12 -0
  18. package/lib/history.d.ts +4 -0
  19. package/lib/history.js +8 -0
  20. package/lib/hooks/useChatSnapshot.d.ts +4 -0
  21. package/lib/hooks/useChatSnapshot.js +9 -0
  22. package/lib/index.d.ts +2 -0
  23. package/lib/index.js +2 -0
  24. package/lib/internal/chatEngine.d.ts +131 -0
  25. package/lib/internal/chatEngine.js +280 -0
  26. package/lib/internal/chatRegistry.d.ts +4 -0
  27. package/lib/internal/chatRegistry.js +4 -0
  28. package/lib/internal/createPublicChat.d.ts +4 -0
  29. package/lib/internal/createPublicChat.js +52 -0
  30. package/lib/messageView.d.ts +6 -0
  31. package/lib/messageView.js +103 -0
  32. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  33. package/lib/runtime/batchedReadIndex.js +50 -4
  34. package/lib/runtime/cachedMessage.d.ts +12 -0
  35. package/lib/runtime/cachedMessage.js +18 -0
  36. package/lib/runtime/chatClient.d.ts +2 -0
  37. package/lib/runtime/chatClient.js +8 -3
  38. package/lib/runtime/chatError.d.ts +3 -0
  39. package/lib/runtime/chatError.js +15 -0
  40. package/lib/runtime/chatSession.d.ts +127 -13
  41. package/lib/runtime/chatSession.js +727 -40
  42. package/lib/runtime/connectivity.d.ts +4 -0
  43. package/lib/runtime/connectivity.js +8 -0
  44. package/lib/runtime/conversationDirectory.d.ts +37 -0
  45. package/lib/runtime/conversationDirectory.js +116 -0
  46. package/lib/runtime/conversationRead.d.ts +13 -0
  47. package/lib/runtime/conversationRead.js +34 -0
  48. package/lib/runtime/conversationReading.d.ts +28 -0
  49. package/lib/runtime/conversationReading.js +47 -0
  50. package/lib/runtime/createChat.d.ts +11 -7
  51. package/lib/runtime/createChat.js +46 -23
  52. package/lib/runtime/durableSender.d.ts +97 -14
  53. package/lib/runtime/durableSender.js +786 -71
  54. package/lib/runtime/groupClient.d.ts +76 -0
  55. package/lib/runtime/groupClient.js +325 -0
  56. package/lib/runtime/index.d.ts +7 -0
  57. package/lib/runtime/index.js +5 -0
  58. package/lib/runtime/messageClient.d.ts +35 -8
  59. package/lib/runtime/messageClient.js +364 -66
  60. package/lib/runtime/messageControls.d.ts +19 -0
  61. package/lib/runtime/messageControls.js +44 -0
  62. package/lib/runtime/messageLaunch.d.ts +44 -0
  63. package/lib/runtime/messageLaunch.js +137 -0
  64. package/lib/runtime/messageQuotes.d.ts +3 -0
  65. package/lib/runtime/messageQuotes.js +17 -0
  66. package/lib/runtime/messageReaders.d.ts +12 -1
  67. package/lib/runtime/messageReaders.js +67 -3
  68. package/lib/runtime/messageStore.d.ts +40 -2
  69. package/lib/runtime/messageStore.js +227 -11
  70. package/lib/runtime/messageTimeline.d.ts +50 -0
  71. package/lib/runtime/messageTimeline.js +120 -0
  72. package/lib/runtime/outboxSchema.d.ts +5 -0
  73. package/lib/runtime/outboxSchema.js +59 -0
  74. package/lib/runtime/outgoingContent.d.ts +89 -0
  75. package/lib/runtime/outgoingContent.js +113 -0
  76. package/lib/runtime/readBatchJournal.d.ts +35 -0
  77. package/lib/runtime/readBatchJournal.js +210 -0
  78. package/lib/runtime/readCounts.d.ts +15 -1
  79. package/lib/runtime/readCounts.js +97 -9
  80. package/lib/runtime/readIndex.d.ts +68 -7
  81. package/lib/runtime/readIndex.js +460 -22
  82. package/lib/runtime/readSchema.d.ts +2 -0
  83. package/lib/runtime/readSchema.js +58 -0
  84. package/lib/runtime/readSync.d.ts +1 -0
  85. package/lib/runtime/readSync.js +26 -9
  86. package/lib/runtime/readingClient.d.ts +19 -0
  87. package/lib/runtime/readingClient.js +131 -19
  88. package/lib/runtime/readingContent.d.ts +5 -0
  89. package/lib/runtime/readingContent.js +23 -0
  90. package/lib/runtime/responseCache.d.ts +59 -0
  91. package/lib/runtime/responseCache.js +232 -0
  92. package/lib/runtime/socialReadClient.d.ts +4 -2
  93. package/lib/runtime/socialReadClient.js +12 -8
  94. package/lib/runtime/voiceUpload.d.ts +2 -0
  95. package/lib/runtime/voiceUpload.js +27 -0
  96. package/package.json +27 -2
@@ -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,98 @@ 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
+ }
103
+ authorizingScope;
104
+ /** Recovery belongs to the SDK; withdrawn instances never regain authority. */
105
+ authorizeScope() {
106
+ return this.authorizingScope ??= (async () => {
107
+ await this.ensureScope();
108
+ if (this.closed || this.closing || this.scopeDenied)
109
+ throw accessRevoked();
110
+ const access = await this.scopeAccess();
111
+ if (access.allowed)
112
+ return;
113
+ if (access.version !== this.scopeVersion) {
114
+ this.denyScope();
115
+ throw accessRevoked();
116
+ }
117
+ if (!this.options.transport.authorizeScope)
118
+ throw accessRevoked();
119
+ try {
120
+ await this.options.transport.authorizeScope(this.abort.signal);
121
+ if (this.closed || this.closing || this.scopeDenied)
122
+ throw accessRevoked();
123
+ await this.storage.authorizeScope(this.scopeVersion);
124
+ if (this.closed || this.closing || this.scopeDenied)
125
+ throw accessRevoked();
126
+ this.scopeAllowed = true;
127
+ }
128
+ catch (error) {
129
+ if ([401, 403].includes(error?.status ?? 0) || String(error).includes("MESSAGE_SCOPE_CHANGED"))
130
+ this.denyScope();
131
+ throw error;
132
+ }
133
+ })().finally(() => { this.authorizingScope = undefined; });
134
+ }
62
135
  getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
136
+ /** Exact authoritative row only; migration-only rows never prove delivery. */
137
+ async cachedMessage(thread, id) {
138
+ await this.ensureScope();
139
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
140
+ throw accessRevoked();
141
+ const page = await this.storage.readSelected(thread, [id]);
142
+ if (page.coverage?.revoked || (page.coverage?.scopeVersion ?? 0) !== this.scopeVersion)
143
+ throw accessRevoked();
144
+ return page.messages[0];
145
+ }
63
146
  subscribe(thread, listener) {
64
147
  let watch = this.watches.get(thread);
65
148
  if (!watch) {
@@ -71,16 +154,18 @@ export class MessageClient {
71
154
  }
72
155
  subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
73
156
  /** 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);
157
+ async restore(since, threadId) {
158
+ if (this.closed || this.closing || this.scopeDenied || isOffline())
159
+ return;
160
+ const queue = (await this.storage.windows()).filter(window => (window.syncedAt ?? 0) < since && !window.revoked && (threadId === undefined || window.thread === threadId));
76
161
  await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
77
- while (queue.length && !this.closed && !this.closing) {
162
+ while (queue.length && !this.closed && !this.closing && !this.scopeDenied && !isOffline()) {
78
163
  const window = queue.shift();
79
164
  try {
80
165
  await this.synchronize(window.thread);
81
166
  }
82
167
  catch (error) {
83
- if (!this.closed && !this.closing)
168
+ if (!this.closed && !this.closing && !isOfflineNetworkFailure(error))
84
169
  this.options.onError?.(error, window.thread);
85
170
  }
86
171
  }
@@ -95,11 +180,22 @@ export class MessageClient {
95
180
  if (running)
96
181
  return running;
97
182
  const task = (async () => {
183
+ const completedAtStart = this.completedSyncs.get(thread);
184
+ await this.ensureScope();
185
+ if (this.scopeDenied)
186
+ throw accessRevoked();
98
187
  const local = await this.storage.read(thread, limit);
188
+ if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
189
+ this.denyScope();
190
+ throw new Error("MESSAGE_SCOPE_CHANGED");
191
+ }
99
192
  if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
100
193
  if (typeof navigator !== "undefined" && navigator.onLine === false) {
101
- if (!refresh && local.messages.length && !local.coverage?.revoked)
102
- return this.reload(thread);
194
+ if (!refresh && !local.coverage?.revoked) {
195
+ const fallback = await this.fallback(thread, limit);
196
+ if (fallback.messages.length)
197
+ return fallback;
198
+ }
103
199
  throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
104
200
  }
105
201
  this.revoked.delete(thread);
@@ -107,10 +203,18 @@ export class MessageClient {
107
203
  await this.synchronize(thread);
108
204
  }
109
205
  catch (error) {
110
- if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
206
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
207
+ this.denyScope();
111
208
  throw error;
112
- this.options.onError?.(error, thread);
113
- return this.reload(thread);
209
+ }
210
+ if (refresh || [401, 403].includes(error?.status ?? 0))
211
+ throw error;
212
+ const fallback = await this.fallback(thread, limit);
213
+ if (!fallback.messages.length)
214
+ throw error;
215
+ if (!isOfflineNetworkFailure(error))
216
+ this.options.onError?.(error, thread);
217
+ return fallback;
114
218
  }
115
219
  }
116
220
  if (this.closed)
@@ -120,17 +224,24 @@ export class MessageClient {
120
224
  result = await this.fillWindow(thread, limit);
121
225
  }
122
226
  catch (error) {
227
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
228
+ this.denyScope();
229
+ throw error;
230
+ }
123
231
  if ([401, 403].includes(error?.status ?? 0))
124
232
  throw error;
125
- const cached = await this.storage.read(thread, limit);
233
+ const cached = await this.fallback(thread, limit);
126
234
  if (!cached.messages.length)
127
235
  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 };
236
+ if (!isOfflineNetworkFailure(error))
237
+ this.options.onError?.(error, thread);
238
+ return cached;
131
239
  }
132
240
  const snapshot = await this.reload(thread);
241
+ if (this.scopeDenied || this.revoked.has(thread))
242
+ throw accessRevoked();
133
243
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
244
+ && !this.syncing.has(thread) && this.completedSyncs.get(thread) === completedAtStart
134
245
  && (typeof navigator === "undefined" || navigator.onLine !== false))
135
246
  this.invalidate(thread);
136
247
  return limit === 100 ? snapshot : result;
@@ -141,15 +252,86 @@ export class MessageClient {
141
252
  }
142
253
  /** Application connection/wake events invalidate, rather than fetching directly. */
143
254
  invalidate(thread) {
144
- if (!this.closed && !this.closing && !this.revoked.has(thread))
145
- this.syncTasks.schedule(thread, () => this.synchronize(thread));
255
+ if (!this.closed && !this.closing && !this.scopeDenied && !isOffline() && !this.revoked.has(thread))
256
+ this.syncTasks.schedule(thread, async () => {
257
+ if (!isOffline())
258
+ await this.synchronize(thread);
259
+ });
146
260
  }
147
- async reload(thread) {
261
+ async fallback(thread, limit, before) {
262
+ await this.ensureScope();
263
+ if (this.scopeDenied || this.revoked.has(thread))
264
+ throw accessRevoked();
265
+ const page = await this.storage.readFallback(thread, limit, before);
266
+ if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
267
+ this.denyScope();
268
+ throw new Error("MESSAGE_SCOPE_CHANGED");
269
+ }
270
+ if (!before && page.messages.length)
271
+ await this.reload(thread, true);
272
+ return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
273
+ }
274
+ /** Migration batches remain separate from authoritative message coverage. */
275
+ importLegacy(thread, messages) {
276
+ if (this.closed || this.closing)
277
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
278
+ if (messages.length > 128)
279
+ return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
280
+ if (this.legacyImports >= 4)
281
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
282
+ let records;
283
+ try {
284
+ let bytes = 0;
285
+ for (const message of messages) {
286
+ bytes += this.messageBytes(message);
287
+ if (bytes > Math.min(this.transactionBytes, 1_048_576))
288
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
289
+ }
290
+ records = structuredClone(messages);
291
+ }
292
+ catch (error) {
293
+ return Promise.reject(error);
294
+ }
295
+ this.legacyImports++;
296
+ const task = this.storage.importLegacy(thread, records).then(async () => {
297
+ const page = await this.storage.read(thread, 1);
298
+ await this.reload(thread, !page.coverage?.complete);
299
+ this.channel?.postMessage({ type: "legacy", thread });
300
+ }).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
301
+ this.activeWrites.add(task);
302
+ return task;
303
+ }
304
+ async reload(thread, fallback = false) {
305
+ await this.ensureScope();
148
306
  const limit = 100;
149
- const page = await this.storage.read(thread, limit);
307
+ let page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
308
+ const edge = this.windowEdges.get(thread);
309
+ if (edge && edge.version !== (page.coverage?.resetVersion ?? 0))
310
+ this.windowEdges.delete(thread);
311
+ if (edge && edge.version === (page.coverage?.resetVersion ?? 0)) {
312
+ const pages = [page.messages];
313
+ const beforeEdge = (message) => message.timestamp < edge.timestamp || message.timestamp === edge.timestamp && message.id < edge.id;
314
+ let oldest = page.messages[0];
315
+ while (oldest && !beforeEdge(oldest) && (oldest.timestamp !== edge.timestamp || oldest.id !== edge.id) && page.hasEarlier) {
316
+ 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 }));
317
+ if ((next.coverage?.resetVersion ?? 0) !== edge.version || (next.coverage?.scopeVersion ?? 0) !== (page.coverage?.scopeVersion ?? 0)) {
318
+ this.windowEdges.delete(thread);
319
+ return this.reload(thread, fallback);
320
+ }
321
+ pages.push(next.messages);
322
+ page = next;
323
+ oldest = next.messages[0];
324
+ }
325
+ const messages = pages.reverse().flat();
326
+ page = { ...page, messages: messages.filter(message => !beforeEdge(message)), hasEarlier: page.hasEarlier || messages.some(beforeEdge) };
327
+ }
150
328
  if (this.closed)
151
329
  throw new Error("MESSAGE_CLIENT_CLOSED");
152
- if (this.revoked.has(thread)) {
330
+ if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
331
+ this.denyScope();
332
+ throw new Error("MESSAGE_SCOPE_CHANGED");
333
+ }
334
+ if (this.scopeDenied || this.revoked.has(thread)) {
153
335
  page.messages = [];
154
336
  page.hasEarlier = false;
155
337
  }
@@ -160,7 +342,7 @@ export class MessageClient {
160
342
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
161
343
  });
162
344
  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);
345
+ 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
346
  const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
165
347
  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
348
  return old;
@@ -179,8 +361,7 @@ export class MessageClient {
179
361
  if (this.closed || this.closing)
180
362
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
181
363
  if (this.revoked.has(thread))
182
- return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
183
- const queue = this.writes.get(thread) ?? [];
364
+ return Promise.reject(accessRevoked());
184
365
  if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
185
366
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
186
367
  let bytes = 0;
@@ -197,15 +378,25 @@ export class MessageClient {
197
378
  }
198
379
  this.pendingBytes += bytes;
199
380
  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;
381
+ let resolve, reject;
382
+ const committed = new Promise((yes, no) => { resolve = yes; reject = no; });
383
+ const enqueue = this.ensureScope().then(() => {
384
+ if (this.closed || this.scopeDenied)
385
+ throw accessRevoked();
386
+ if (!this.scopeAllowed)
387
+ throw new Error("MESSAGE_SCOPE_NOT_READY");
388
+ const queue = this.writes.get(thread) ?? [];
389
+ queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, scopeVersion: this.scopeVersion, resolve, reject });
390
+ this.writes.set(thread, queue);
391
+ this.writeTasks.schedule(thread, () => this.flushWrites(thread));
392
+ }).catch(error => { this.pendingBytes -= bytes; this.pendingRecords -= messages.length; reject(error); }).finally(() => this.enqueuing.delete(enqueue));
393
+ this.enqueuing.add(enqueue);
394
+ return committed;
204
395
  }
205
- async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
396
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion, expectedScopeVersion) {
206
397
  const initial = (await this.storage.read(thread, 1)).coverage;
207
398
  const version = expectedVersion ?? initial?.resetVersion ?? 0;
208
- const guard = { version, authoritative: complete === true };
399
+ const guard = { version, scopeVersion: expectedScopeVersion ?? this.scopeVersion, authoritative: complete === true };
209
400
  let changed = 0;
210
401
  let navigationChanged = false;
211
402
  // Coverage advances only after all bounded batches have durably committed.
@@ -213,7 +404,7 @@ export class MessageClient {
213
404
  const commit = async () => {
214
405
  if (!batch.length)
215
406
  return;
216
- if (this.closed || this.revoked.has(thread))
407
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
217
408
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
218
409
  changed += await this.storage.commit(thread, batch, undefined, guard);
219
410
  batch = [];
@@ -229,11 +420,13 @@ export class MessageClient {
229
420
  await commit();
230
421
  if (complete !== undefined || checkpoint !== undefined) {
231
422
  const old = (await this.storage.read(thread, 1)).coverage;
232
- if (this.closed || this.revoked.has(thread))
423
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
233
424
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
234
425
  navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
235
426
  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
427
  }
428
+ if (complete === true)
429
+ this.scopeAllowed = true;
237
430
  if (changed || navigationChanged) {
238
431
  await this.reload(thread);
239
432
  this.channel?.postMessage(thread);
@@ -264,14 +457,14 @@ export class MessageClient {
264
457
  try {
265
458
  const groups = new Map();
266
459
  for (const write of queue) {
267
- const group = groups.get(write.version) ?? [];
460
+ const key = `${write.version}:${write.scopeVersion}`, group = groups.get(key) ?? [];
268
461
  group.push(write);
269
- groups.set(write.version, group);
462
+ groups.set(key, group);
270
463
  }
271
464
  let failed = false, failure;
272
- for (const [version, writes] of groups) {
465
+ for (const writes of groups.values()) {
273
466
  try {
274
- await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
467
+ await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, writes[0].version, writes[0].scopeVersion);
275
468
  writes.forEach(write => write.resolve());
276
469
  }
277
470
  catch (error) {
@@ -287,40 +480,58 @@ export class MessageClient {
287
480
  this.release(queue);
288
481
  }
289
482
  }
483
+ assertNetworkAvailable(thread) {
484
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
485
+ throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
486
+ if (isOffline())
487
+ throw new TypeError("MESSAGE_OFFLINE_UNAVAILABLE");
488
+ }
290
489
  synchronize(thread) {
291
- if (this.closed || this.closing || this.revoked.has(thread))
490
+ if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
292
491
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
293
492
  const running = this.syncing.get(thread);
294
493
  if (running)
295
494
  return running;
296
495
  const task = (async () => {
496
+ await this.ensureScope();
297
497
  const coverage = (await this.storage.read(thread, 1)).coverage;
498
+ const scopeVersion = coverage?.scopeVersion ?? 0;
499
+ if (scopeVersion !== this.scopeVersion) {
500
+ this.denyScope();
501
+ throw new Error("MESSAGE_SCOPE_CHANGED");
502
+ }
298
503
  let version = coverage?.resetVersion ?? 0;
299
504
  let after = coverage?.complete ? coverage.checkpoint : undefined;
505
+ this.assertNetworkAvailable(thread);
300
506
  let page = after && this.options.transport.changes
301
507
  ? await this.options.transport.changes(thread, after, this.abort.signal)
302
508
  : await this.options.transport.load(thread, this.abort.signal);
303
509
  if (page.resetRequired) {
304
- if (this.closed || this.revoked.has(thread))
510
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
305
511
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
306
- version = await this.storage.resetThread(thread);
512
+ version = await this.storage.resetThread(thread, false, scopeVersion);
307
513
  after = undefined;
514
+ this.assertNetworkAvailable(thread);
308
515
  page = await this.options.transport.load(thread, this.abort.signal);
309
516
  }
310
517
  for (;;) {
311
- if (this.closed || this.revoked.has(thread))
312
- throw new Error("MESSAGE_ACCESS_REVOKED");
518
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
519
+ throw accessRevoked();
313
520
  if (page.resetRequired)
314
521
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
315
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
522
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
316
523
  if (!page.hasMoreChanges)
317
524
  break;
318
525
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
319
526
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
320
527
  after = page.checkpoint;
528
+ this.assertNetworkAvailable(thread);
321
529
  page = await this.options.transport.changes(thread, after, this.abort.signal);
322
530
  }
531
+ this.completedSyncs.set(thread, (this.completedSyncs.get(thread) ?? 0) + 1);
323
532
  })().catch(async (error) => {
533
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
534
+ this.denyScope();
324
535
  if (!this.closed && !this.closing && error?.status === 403) {
325
536
  try {
326
537
  await this.revoke(thread);
@@ -335,14 +546,51 @@ export class MessageClient {
335
546
  this.syncing.set(thread, task);
336
547
  return task;
337
548
  }
338
- async earlier(thread, before, limit = 100) {
549
+ /** Keep an explicitly requested history range in the shared subscribed view. */
550
+ async expandWindow(thread, page) {
551
+ const first = page.messages[0];
552
+ if (!first || !page.complete)
553
+ return;
554
+ const old = this.windowEdges.get(thread);
555
+ if (!old || old.version !== page.resetVersion || first.timestamp < old.timestamp || first.timestamp === old.timestamp && first.id < old.id)
556
+ this.windowEdges.set(thread, { timestamp: first.timestamp, id: first.id, version: page.resetVersion });
557
+ const snapshot = await this.reload(thread);
558
+ if (snapshot.resetVersion !== page.resetVersion)
559
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
560
+ }
561
+ async earlier(thread, before, limit = 100, expand = false) {
562
+ await this.ensureScope();
563
+ if (this.scopeDenied)
564
+ throw accessRevoked();
339
565
  if (this.closed)
340
566
  throw new Error("MESSAGE_CLIENT_CLOSED");
341
567
  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);
568
+ throw accessRevoked();
569
+ try {
570
+ if (!(await this.storage.read(thread, 1)).coverage?.complete) {
571
+ if (typeof navigator !== "undefined" && navigator.onLine === false)
572
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
573
+ await this.synchronize(thread);
574
+ }
575
+ const page = await this.fillWindow(thread, limit, before);
576
+ if (expand)
577
+ await this.expandWindow(thread, page);
578
+ return page;
579
+ }
580
+ catch (error) {
581
+ if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
582
+ this.denyScope();
583
+ throw error;
584
+ }
585
+ if ([401, 403].includes(error?.status ?? 0))
586
+ throw error;
587
+ const fallback = await this.fallback(thread, limit, before);
588
+ if (!fallback.messages.length)
589
+ throw error;
590
+ if (!isOfflineNetworkFailure(error))
591
+ this.options.onError?.(error, thread);
592
+ return fallback;
593
+ }
346
594
  }
347
595
  async fillWindow(thread, limit, before) {
348
596
  let page = await this.storage.read(thread, limit, before);
@@ -354,21 +602,22 @@ export class MessageClient {
354
602
  page = await this.storage.read(thread, limit, before);
355
603
  continue;
356
604
  }
357
- const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
605
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0, scopeVersion = page.coverage.scopeVersion ?? 0;
358
606
  const task = (async () => {
607
+ this.assertNetworkAvailable(thread);
359
608
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
360
- if (this.closed || this.revoked.has(thread))
609
+ if (this.closed || this.scopeDenied || this.revoked.has(thread))
361
610
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
362
611
  if (remote.resetRequired) {
363
612
  if (++resets > 2)
364
613
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
365
- await this.storage.resetThread(thread);
614
+ await this.storage.resetThread(thread, false, scopeVersion);
366
615
  await this.synchronize(thread);
367
616
  return;
368
617
  }
369
618
  if (remote.nextBefore === token)
370
619
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
371
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
620
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version, scopeVersion);
372
621
  })().catch(async (error) => {
373
622
  if (!this.closed && !this.closing && error?.status === 403) {
374
623
  try {
@@ -385,9 +634,12 @@ export class MessageClient {
385
634
  await task;
386
635
  page = await this.storage.read(thread, limit, before);
387
636
  }
637
+ if (this.scopeDenied || this.revoked.has(thread))
638
+ throw accessRevoked();
388
639
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
389
640
  }
390
641
  denyView(thread, version) {
642
+ this.windowEdges.delete(thread);
391
643
  this.revoked.add(thread);
392
644
  const watch = this.watches.get(thread);
393
645
  const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
@@ -401,34 +653,80 @@ export class MessageClient {
401
653
  this.watches.set(thread, { snapshot, listeners: new Set() });
402
654
  this.allListeners.forEach(listener => listener(thread));
403
655
  }
656
+ denyScope() {
657
+ const notify = !this.scopeDenied;
658
+ this.scopeDenied = true;
659
+ this.scopeAllowed = false;
660
+ this.abort.abort();
661
+ this.syncTasks.close();
662
+ this.writeTasks.close();
663
+ for (const thread of this.watches.keys())
664
+ this.denyView(thread);
665
+ for (const queue of this.writes.values()) {
666
+ queue.forEach(write => write.reject(accessRevoked()));
667
+ this.release(queue);
668
+ }
669
+ this.writes.clear();
670
+ if (notify)
671
+ this.accessListeners.forEach(listener => listener());
672
+ }
673
+ revokeScope() {
674
+ if (this.closed || this.closing)
675
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
676
+ return this.revokingScope ??= (async () => {
677
+ this.denyScope();
678
+ try {
679
+ await this.ensureScope();
680
+ return await this.storage.revokeScope(this.scopeVersion);
681
+ }
682
+ finally {
683
+ const access = await this.storage.scopeAccess();
684
+ this.channel?.postMessage({ type: "scope-revoked", version: access.version });
685
+ }
686
+ })();
687
+ }
404
688
  async revoke(thread) {
405
689
  this.denyView(thread);
406
690
  const queue = this.writes.get(thread);
407
691
  this.writes.delete(thread);
408
692
  if (queue) {
409
- queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
693
+ queue.forEach(write => write.reject(accessRevoked()));
410
694
  this.release(queue);
411
695
  }
412
696
  try {
413
- await this.storage.resetThread(thread, true);
697
+ await this.storage.resetThread(thread, true, this.scopeVersion);
414
698
  await this.reload(thread);
415
699
  }
416
700
  finally {
417
- this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
701
+ this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion, scopeVersion: this.scopeVersion });
418
702
  }
419
703
  }
420
- async close() {
421
- // Flush accepted event batches before closing; new requests are rejected afterwards.
704
+ async close(beforeDispose) {
705
+ if (this.closed)
706
+ return;
422
707
  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();
708
+ try {
709
+ await Promise.all([...this.enqueuing]);
710
+ this.syncTasks.close();
711
+ this.writeTasks.close();
712
+ const flushes = [...this.writes.keys()].map(thread => this.flushWrites(thread));
713
+ const results = await Promise.allSettled([...flushes, ...this.activeWrites, ...(this.revokingScope ? [this.revokingScope] : [])]);
714
+ const failed = results.find((result) => result.status === "rejected");
715
+ if (failed)
716
+ throw failed.reason;
717
+ await beforeDispose;
718
+ }
719
+ finally {
720
+ this.closed = true;
721
+ this.abort.abort();
722
+ this.channel?.close();
723
+ this.watches.clear();
724
+ this.windowEdges.clear();
725
+ this.completedSyncs.clear();
726
+ this.allListeners.clear();
727
+ this.accessListeners.clear();
728
+ this.storage.close();
729
+ }
433
730
  }
434
731
  }
732
+ function accessRevoked() { return Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }); }