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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -143,6 +143,9 @@ The browser entry point is `createChat` from `@pmate/chat-sdk/runtime`:
143
143
  const chat = createChat({ appId, accountId, profileId, teamId, token, onError })
144
144
  await chat.start()
145
145
  const page = await chat.getMessages(threadId, { limit: 50 })
146
+ // After an authoritative mutation (e.g. history assignment), await reconciliation.
147
+ // Concurrent refreshes share transport; errors do not masquerade as fresh cache.
148
+ const reconciled = await chat.getMessages(threadId, { refresh: true })
146
149
  // Explicit full-history queries reuse cached pages and internally rebase expired cursors.
147
150
  const history = await chat.getMessages(threadId, { all: true })
148
151
  const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
@@ -47,6 +47,8 @@ export declare class ChatSession<T extends StoredMessage> {
47
47
  private globalListeners;
48
48
  private globalSnapshot?;
49
49
  private connectionState;
50
+ private catchUp;
51
+ private readingStarted;
50
52
  private closed;
51
53
  private starting?;
52
54
  private closing?;
@@ -60,6 +62,7 @@ export declare class ChatSession<T extends StoredMessage> {
60
62
  id: string;
61
63
  };
62
64
  all?: boolean;
65
+ refresh?: boolean;
63
66
  }): Promise<MessageSnapshot<T>>;
64
67
  private readAll;
65
68
  sendMessage(threadId: string, input: {
@@ -1,3 +1,4 @@
1
+ import { WindowedTasks } from "./coalescer.js";
1
2
  import { MessageReadersClient } from "./messageReaders.js";
2
3
  import { MessageClient } from "./messageClient.js";
3
4
  import { DurableChatSender } from "./durableSender.js";
@@ -18,6 +19,8 @@ export class ChatSession {
18
19
  globalListeners = new Set();
19
20
  globalSnapshot;
20
21
  connectionState = "closed";
22
+ catchUp;
23
+ readingStarted = false;
21
24
  closed = false;
22
25
  starting;
23
26
  closing;
@@ -36,12 +39,18 @@ export class ChatSession {
36
39
  this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
37
40
  this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
38
41
  this.sender = new DurableChatSender(this.connection, scope, options.onError);
42
+ this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
43
+ options.onError(error); });
44
+ this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
39
45
  this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
40
46
  this.off.push(this.connection.watchMessages(message => {
41
47
  try {
42
48
  const decoded = options.decode(message);
43
49
  if (decoded)
44
- void this.messages.receive(decoded.threadId, [decoded]).then(() => { this.publish(decoded.threadId); return this.sender.confirm([message.hash]); }).catch(options.onError);
50
+ void this.messages.receive(decoded.threadId, [decoded]).then(() => this.sender.confirm([message.hash])).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
51
+ this.messages.invalidate(decoded.threadId);
52
+ else if (!this.closed)
53
+ options.onError(error); });
45
54
  }
46
55
  catch (error) {
47
56
  options.onError(error);
@@ -51,9 +60,10 @@ export class ChatSession {
51
60
  this.connectionState = state;
52
61
  this.publishGlobal();
53
62
  if (state === "connected") {
54
- for (const thread of this.listeners.keys())
55
- this.messages.invalidate(thread);
56
- this.reading.refresh();
63
+ const since = Date.now();
64
+ this.catchUp.schedule("scope", () => this.messages.restore(since));
65
+ if (this.readingStarted)
66
+ this.reading.refresh();
57
67
  }
58
68
  }));
59
69
  }
@@ -61,6 +71,7 @@ export class ChatSession {
61
71
  this.assertOpen();
62
72
  return this.starting ??= (async () => {
63
73
  await this.sender.initialize();
74
+ this.readingStarted = true;
64
75
  await this.reading.start();
65
76
  this.assertOpen();
66
77
  await this.connection.connect();
@@ -70,15 +81,17 @@ export class ChatSession {
70
81
  throw new Error("CHAT_SESSION_CLOSED"); }
71
82
  async getMessages(threadId, options = {}) {
72
83
  this.assertOpen();
84
+ if (options.all && (options.before || options.limit !== undefined))
85
+ throw new Error("INVALID_MESSAGE_QUERY");
86
+ // Explicit authoritative changes (for example history assignment) can invalidate
87
+ // even a recently cached empty window. Keep this on the existing read surface.
88
+ if (options.refresh)
89
+ await this.messages.read(threadId, options.limit ?? 100, true);
73
90
  if (options.all) {
74
- if (options.before || options.limit !== undefined)
75
- throw new Error("INVALID_MESSAGE_QUERY");
76
91
  return this.readAll(threadId);
77
92
  }
78
93
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
79
94
  await this.sender.confirm(page.messages.map(message => message.id));
80
- if (!options.before)
81
- this.publish(threadId);
82
95
  return page;
83
96
  }
84
97
  async readAll(threadId) {
@@ -161,7 +174,7 @@ export class ChatSession {
161
174
  this.listeners.set(threadId, listeners);
162
175
  }
163
176
  listeners.add(notify);
164
- const off = this.messages.subscribe(threadId, () => this.publish(threadId));
177
+ const off = this.messages.subscribe(threadId, () => { });
165
178
  return () => {
166
179
  off();
167
180
  listeners.delete(notify);
@@ -209,6 +222,7 @@ export class ChatSession {
209
222
  close() {
210
223
  return this.closing ??= (async () => {
211
224
  this.closed = true;
225
+ this.catchUp.close();
212
226
  this.off.splice(0).forEach(off => off());
213
227
  if (!this.options.sharedConnection)
214
228
  this.connection.close();
@@ -36,6 +36,7 @@ export declare class MessageClient<T extends StoredMessage> {
36
36
  private options;
37
37
  private storage;
38
38
  private watches;
39
+ private allListeners;
39
40
  private reads;
40
41
  private syncing;
41
42
  private paging;
@@ -56,7 +57,10 @@ export declare class MessageClient<T extends StoredMessage> {
56
57
  constructor(options: MessageClientOptions<T>);
57
58
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
58
59
  subscribe(thread: string, listener: () => void): () => void;
59
- read(thread: string, limit?: number): Promise<MessageSnapshot<T>>;
60
+ subscribeAll(listener: (threadId: string) => void): () => void;
61
+ /** Close the connection gap for saved windows with four bounded background lanes. */
62
+ restore(since: number): Promise<void>;
63
+ read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
60
64
  /** Application connection/wake events invalidate, rather than fetching directly. */
61
65
  invalidate(thread: string): void;
62
66
  private reload;
@@ -78,6 +82,7 @@ export declare class MessageClient<T extends StoredMessage> {
78
82
  complete: boolean;
79
83
  }>;
80
84
  private fillWindow;
85
+ private denyView;
81
86
  revoke(thread: string): Promise<void>;
82
87
  close(): Promise<void>;
83
88
  }
@@ -5,6 +5,7 @@ export class MessageClient {
5
5
  options;
6
6
  storage;
7
7
  watches = new Map();
8
+ allListeners = new Set();
8
9
  reads = new Map();
9
10
  syncing = new Map();
10
11
  paging = new Map();
@@ -32,12 +33,30 @@ export class MessageClient {
32
33
  throw new Error("INVALID_MESSAGE_BYTE_LIMIT");
33
34
  if (!Number.isSafeInteger(this.batchSize) || this.batchSize < 1 || this.batchSize > 1000)
34
35
  throw new Error("INVALID_MESSAGE_BATCH_SIZE");
35
- this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => options.onError?.(error, thread));
36
+ this.writeTasks = new WindowedTasks(options.writeWindowMs ?? 16, (error, thread) => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
37
+ this.invalidate(thread);
38
+ else
39
+ options.onError?.(error, thread); });
36
40
  this.syncTasks = new WindowedTasks(options.syncWindowMs ?? 1000, (error, thread) => options.onError?.(error, thread));
37
41
  if (typeof BroadcastChannel !== "undefined") {
38
- this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}`);
39
- this.channel.onmessage = event => { if (typeof event.data === "string" && this.watches.has(event.data))
40
- void this.reload(event.data).catch(error => options.onError?.(error, event.data)); };
42
+ this.channel = new BroadcastChannel(`pmate-chat:${messageScopeKey(options.scope)}${this.storage.namespace === "pmate-chat-messages" ? "" : `:${this.storage.namespace}`}`);
43
+ this.channel.onmessage = event => {
44
+ if (this.closed)
45
+ return;
46
+ if (event.data?.type === "revoked" && typeof event.data.thread === "string" && Number.isSafeInteger(event.data.version)) {
47
+ const denied = event.data;
48
+ void (async () => {
49
+ const current = await this.storage.read(denied.thread, 1).then(page => page.coverage).catch(() => undefined);
50
+ if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
51
+ return;
52
+ this.denyView(denied.thread, denied.version);
53
+ options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
54
+ })();
55
+ }
56
+ else if (typeof event.data === "string" && this.watches.has(event.data))
57
+ void this.reload(event.data).catch(error => { if (!this.closed)
58
+ options.onError?.(error, event.data); });
59
+ };
41
60
  }
42
61
  }
43
62
  getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
@@ -50,19 +69,36 @@ export class MessageClient {
50
69
  watch.listeners.add(listener);
51
70
  return () => { watch.listeners.delete(listener); };
52
71
  }
53
- read(thread, limit = 100) {
72
+ subscribeAll(listener) { this.allListeners.add(listener); return () => { this.allListeners.delete(listener); }; }
73
+ /** 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);
76
+ await Promise.all(Array.from({ length: Math.min(4, queue.length) }, async () => {
77
+ while (queue.length && !this.closed && !this.closing) {
78
+ const window = queue.shift();
79
+ try {
80
+ await this.synchronize(window.thread);
81
+ }
82
+ catch (error) {
83
+ if (!this.closed && !this.closing)
84
+ this.options.onError?.(error, window.thread);
85
+ }
86
+ }
87
+ }));
88
+ }
89
+ read(thread, limit = 100, refresh = false) {
54
90
  if (this.closed || this.closing)
55
91
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
56
92
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
57
93
  return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
58
- const key = JSON.stringify([thread, limit]), running = this.reads.get(key);
94
+ const key = JSON.stringify([thread, limit, refresh]), running = this.reads.get(key);
59
95
  if (running)
60
96
  return running;
61
97
  const task = (async () => {
62
98
  const local = await this.storage.read(thread, limit);
63
- if (!local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
99
+ if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
64
100
  if (typeof navigator !== "undefined" && navigator.onLine === false) {
65
- if (local.messages.length && !local.coverage?.revoked)
101
+ if (!refresh && local.messages.length && !local.coverage?.revoked)
66
102
  return this.reload(thread);
67
103
  throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
68
104
  }
@@ -71,7 +107,7 @@ export class MessageClient {
71
107
  await this.synchronize(thread);
72
108
  }
73
109
  catch (error) {
74
- if (!local.messages.length || [401, 403].includes(error?.status ?? 0))
110
+ if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
75
111
  throw error;
76
112
  this.options.onError?.(error, thread);
77
113
  return this.reload(thread);
@@ -113,6 +149,10 @@ export class MessageClient {
113
149
  const page = await this.storage.read(thread, limit);
114
150
  if (this.closed)
115
151
  throw new Error("MESSAGE_CLIENT_CLOSED");
152
+ if (this.revoked.has(thread)) {
153
+ page.messages = [];
154
+ page.hasEarlier = false;
155
+ }
116
156
  let watch = this.watches.get(thread);
117
157
  const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
118
158
  const messages = page.messages.map(message => {
@@ -120,22 +160,26 @@ export class MessageClient {
120
160
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
121
161
  });
122
162
  const old = watch?.snapshot;
123
- const resetVersion = page.coverage?.resetVersion ?? 0, complete = page.coverage?.complete ?? false;
124
- if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
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);
164
+ const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
165
+ if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
125
166
  return old;
126
- const snapshot = { messages, resetVersion, complete, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
167
+ const snapshot = { messages, resetVersion, complete, hasEarlier };
127
168
  if (!watch) {
128
169
  watch = { snapshot, listeners: new Set() };
129
170
  this.watches.set(thread, watch);
130
171
  }
131
172
  watch.snapshot = snapshot;
132
173
  watch.listeners.forEach(listener => listener());
174
+ this.allListeners.forEach(listener => listener(thread));
133
175
  return snapshot;
134
176
  }
135
177
  /** Events are visible only after their atomic batch commits. */
136
178
  receive(thread, messages) {
137
- if (this.closed || this.closing || this.revoked.has(thread))
179
+ if (this.closed || this.closing)
138
180
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
181
+ if (this.revoked.has(thread))
182
+ return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
139
183
  const queue = this.writes.get(thread) ?? [];
140
184
  if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
141
185
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
@@ -153,7 +197,7 @@ export class MessageClient {
153
197
  }
154
198
  this.pendingBytes += bytes;
155
199
  this.pendingRecords += messages.length;
156
- const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, resolve, reject }));
200
+ const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, version: this.watches.get(thread)?.snapshot.resetVersion ?? 0, resolve, reject }));
157
201
  this.writes.set(thread, queue);
158
202
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
159
203
  return promise;
@@ -218,12 +262,26 @@ export class MessageClient {
218
262
  return;
219
263
  this.writes.delete(thread);
220
264
  try {
221
- await this.persist(thread, queue.flatMap(write => write.messages));
222
- queue.forEach(write => write.resolve());
223
- }
224
- catch (error) {
225
- queue.forEach(write => write.reject(error));
226
- throw error;
265
+ const groups = new Map();
266
+ for (const write of queue) {
267
+ const group = groups.get(write.version) ?? [];
268
+ group.push(write);
269
+ groups.set(write.version, group);
270
+ }
271
+ let failed = false, failure;
272
+ for (const [version, writes] of groups) {
273
+ try {
274
+ await this.persist(thread, writes.flatMap(write => write.messages), undefined, undefined, undefined, undefined, version);
275
+ writes.forEach(write => write.resolve());
276
+ }
277
+ catch (error) {
278
+ failed = true;
279
+ failure = error;
280
+ writes.forEach(write => write.reject(error));
281
+ }
282
+ }
283
+ if (failed)
284
+ throw failure;
227
285
  }
228
286
  finally {
229
287
  this.release(queue);
@@ -263,8 +321,14 @@ export class MessageClient {
263
321
  page = await this.options.transport.changes(thread, after, this.abort.signal);
264
322
  }
265
323
  })().catch(async (error) => {
266
- if (!this.closed && !this.closing && error?.status === 403)
267
- await this.revoke(thread);
324
+ if (!this.closed && !this.closing && error?.status === 403) {
325
+ try {
326
+ await this.revoke(thread);
327
+ }
328
+ catch (cacheError) {
329
+ Object.assign(error, { cacheError });
330
+ }
331
+ }
268
332
  throw error;
269
333
  }).finally(() => { if (this.syncing.get(thread) === task)
270
334
  this.syncing.delete(thread); });
@@ -306,8 +370,14 @@ export class MessageClient {
306
370
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
307
371
  await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
308
372
  })().catch(async (error) => {
309
- if (!this.closed && !this.closing && error?.status === 403)
310
- await this.revoke(thread);
373
+ if (!this.closed && !this.closing && error?.status === 403) {
374
+ try {
375
+ await this.revoke(thread);
376
+ }
377
+ catch (cacheError) {
378
+ Object.assign(error, { cacheError });
379
+ }
380
+ }
311
381
  throw error;
312
382
  }).finally(() => { if (this.paging.get(thread) === task)
313
383
  this.paging.delete(thread); });
@@ -317,17 +387,35 @@ export class MessageClient {
317
387
  }
318
388
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
319
389
  }
320
- async revoke(thread) {
390
+ denyView(thread, version) {
321
391
  this.revoked.add(thread);
392
+ const watch = this.watches.get(thread);
393
+ const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
394
+ if (watch && !watch.snapshot.messages.length && !watch.snapshot.complete && !watch.snapshot.hasEarlier && watch.snapshot.resetVersion === snapshot.resetVersion)
395
+ return;
396
+ if (watch) {
397
+ watch.snapshot = snapshot;
398
+ watch.listeners.forEach(listener => listener());
399
+ }
400
+ else
401
+ this.watches.set(thread, { snapshot, listeners: new Set() });
402
+ this.allListeners.forEach(listener => listener(thread));
403
+ }
404
+ async revoke(thread) {
405
+ this.denyView(thread);
322
406
  const queue = this.writes.get(thread);
323
407
  this.writes.delete(thread);
324
408
  if (queue) {
325
409
  queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
326
410
  this.release(queue);
327
411
  }
328
- await this.storage.resetThread(thread, true);
329
- await this.reload(thread);
330
- this.channel?.postMessage(thread);
412
+ try {
413
+ await this.storage.resetThread(thread, true);
414
+ await this.reload(thread);
415
+ }
416
+ finally {
417
+ this.channel?.postMessage({ type: "revoked", thread, version: this.watches.get(thread)?.snapshot.resetVersion });
418
+ }
331
419
  }
332
420
  async close() {
333
421
  // Flush accepted event batches before closing; new requests are rejected afterwards.
@@ -340,6 +428,7 @@ export class MessageClient {
340
428
  this.abort.abort();
341
429
  this.channel?.close();
342
430
  this.watches.clear();
431
+ this.allListeners.clear();
343
432
  this.storage.close();
344
433
  }
345
434
  }
@@ -36,6 +36,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
36
36
  indexedDB?: IDBFactory;
37
37
  name?: string;
38
38
  });
39
+ get namespace(): string;
39
40
  read(thread: string, limit?: number, before?: {
40
41
  timestamp: number;
41
42
  id: string;
@@ -48,6 +49,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
48
49
  version: number;
49
50
  authoritative?: boolean;
50
51
  }): Promise<number>;
52
+ windows(): Promise<CoverageRow[]>;
51
53
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
52
54
  resetThread(thread: string, revoked?: boolean): Promise<number>;
53
55
  removeThread(thread: string): Promise<void>;
@@ -17,6 +17,7 @@ export class IndexedDbMessageStore {
17
17
  this.coverage = this.db.table("coverage");
18
18
  this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
19
19
  }
20
+ get namespace() { return this.db.name; }
20
21
  async read(thread, limit = 100, before) {
21
22
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
22
23
  throw new Error("INVALID_MESSAGE_LIMIT");
@@ -60,6 +61,9 @@ export class IndexedDbMessageStore {
60
61
  return changed.size;
61
62
  });
62
63
  }
64
+ async windows() {
65
+ return this.coverage.where("scope").equals(this.key).toArray();
66
+ }
63
67
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
64
68
  async resetThread(thread, revoked = false) {
65
69
  return this.db.transaction("rw", this.rows, this.coverage, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.5",
3
+ "version": "0.12.0-next.6",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",