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

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,8 @@ 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
+ // Explicit full-history queries reuse cached pages and internally rebase expired cursors.
147
+ const history = await chat.getMessages(threadId, { all: true })
146
148
  const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
147
149
  const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
148
150
  await chat.observeMessages(threadId, visibleIds, "visible")
@@ -162,6 +164,11 @@ snapshot reports transport progress and failure. Retry uses the original message
162
164
  hash. The subscribed recent window remains 100 records regardless of individual
163
165
  query limits; history requests return their own pages and do not replace it.
164
166
  Global snapshots contain thread summaries and totals, not complete message arrays.
167
+ Message pages report `complete`; unavailable older pages return cached records with
168
+ `complete: false`. Authorization failures throw and never fall back to denied
169
+ records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
170
+ it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
171
+ pre-reset payload.
165
172
  Voice visibility never acknowledges playback, including observations that arrive
166
173
  before metadata. Create a new instance and close the previous owner when the
167
174
  account or active Team changes.
@@ -1,5 +1,5 @@
1
1
  import { type MessageReadersQuery } from "./messageReaders.js";
2
- import { type MessageClientOptions } from "./messageClient.js";
2
+ import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
3
3
  import { DurableChatSender } from "./durableSender.js";
4
4
  import { type ReadingClientOptions } from "./readingClient.js";
5
5
  import { ChatClient, type ChatClientOptions, type ChatConnectionState } from "./chatClient.js";
@@ -18,6 +18,8 @@ export type ChatThreadSnapshot<T> = {
18
18
  messages: readonly T[];
19
19
  outgoing: ReturnType<DurableChatSender["snapshot"]>;
20
20
  hasEarlier: boolean;
21
+ resetVersion: number;
22
+ complete: boolean;
21
23
  unread: ReturnType<typeof unreadInConversation>;
22
24
  };
23
25
  export type ChatSummarySnapshot<T> = {
@@ -57,7 +59,9 @@ export declare class ChatSession<T extends StoredMessage> {
57
59
  timestamp: number;
58
60
  id: string;
59
61
  };
60
- }): Promise<import("./messageClient.js").MessageSnapshot<T>>;
62
+ all?: boolean;
63
+ }): Promise<MessageSnapshot<T>>;
64
+ private readAll;
61
65
  sendMessage(threadId: string, input: {
62
66
  to: string;
63
67
  body: ChatBody;
@@ -70,12 +70,55 @@ export class ChatSession {
70
70
  throw new Error("CHAT_SESSION_CLOSED"); }
71
71
  async getMessages(threadId, options = {}) {
72
72
  this.assertOpen();
73
+ if (options.all) {
74
+ if (options.before || options.limit !== undefined)
75
+ throw new Error("INVALID_MESSAGE_QUERY");
76
+ return this.readAll(threadId);
77
+ }
73
78
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
74
79
  await this.sender.confirm(page.messages.map(message => message.id));
75
80
  if (!options.before)
76
81
  this.publish(threadId);
77
82
  return page;
78
83
  }
84
+ async readAll(threadId) {
85
+ for (let attempt = 0; attempt < 3; attempt++) {
86
+ const pages = [];
87
+ let version = 0;
88
+ try {
89
+ let page = await this.getMessages(threadId);
90
+ version = page.resetVersion;
91
+ pages.push(page.messages);
92
+ if (!page.complete)
93
+ return { ...page, hasEarlier: true };
94
+ let restart = false;
95
+ while (page.hasEarlier) {
96
+ const first = page.messages[0];
97
+ if (!first)
98
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
99
+ page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
100
+ if (page.resetVersion !== version) {
101
+ restart = true;
102
+ break;
103
+ }
104
+ pages.push(page.messages);
105
+ }
106
+ if (restart)
107
+ continue;
108
+ return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
109
+ }
110
+ catch (error) {
111
+ if ([401, 403].includes(error?.status ?? 0))
112
+ throw error;
113
+ if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
114
+ continue;
115
+ if (pages.length)
116
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
117
+ throw error;
118
+ }
119
+ }
120
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
121
+ }
79
122
  sendMessage(threadId, input) {
80
123
  this.assertOpen();
81
124
  return this.sender.enqueue(threadId, input);
@@ -99,7 +142,7 @@ export class ChatSession {
99
142
  if (!snapshot) {
100
143
  const messages = this.messages.getSnapshot(threadId);
101
144
  const state = this.reading.getSnapshot();
102
- snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
145
+ snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
103
146
  this.snapshots.set(threadId, snapshot);
104
147
  }
105
148
  return snapshot;
@@ -15,6 +15,8 @@ export type MessageTransport<T> = {
15
15
  export type MessageSnapshot<T> = {
16
16
  messages: readonly T[];
17
17
  hasEarlier: boolean;
18
+ resetVersion: number;
19
+ complete: boolean;
18
20
  };
19
21
  export type MessageClientOptions<T extends StoredMessage> = {
20
22
  scope: MessageScope;
@@ -72,6 +74,8 @@ export declare class MessageClient<T extends StoredMessage> {
72
74
  }, limit?: number): Promise<{
73
75
  messages: T[];
74
76
  hasEarlier: boolean;
77
+ resetVersion: number;
78
+ complete: boolean;
75
79
  }>;
76
80
  private fillWindow;
77
81
  revoke(thread: string): Promise<void>;
@@ -44,14 +44,14 @@ export class MessageClient {
44
44
  subscribe(thread, listener) {
45
45
  let watch = this.watches.get(thread);
46
46
  if (!watch) {
47
- watch = { snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
47
+ watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
48
48
  this.watches.set(thread, watch);
49
49
  }
50
50
  watch.listeners.add(listener);
51
51
  return () => { watch.listeners.delete(listener); };
52
52
  }
53
53
  read(thread, limit = 100) {
54
- if (this.closed || this.closing || this.revoked.has(thread))
54
+ if (this.closed || this.closing)
55
55
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
56
56
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
57
57
  return Promise.reject(new Error("INVALID_MESSAGE_LIMIT"));
@@ -60,11 +60,39 @@ export class MessageClient {
60
60
  return running;
61
61
  const task = (async () => {
62
62
  const local = await this.storage.read(thread, limit);
63
- if (!local.coverage?.complete)
64
- await this.synchronize(thread);
63
+ if (!local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
64
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
65
+ if (local.messages.length && !local.coverage?.revoked)
66
+ return this.reload(thread);
67
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
68
+ }
69
+ this.revoked.delete(thread);
70
+ try {
71
+ await this.synchronize(thread);
72
+ }
73
+ catch (error) {
74
+ if (!local.messages.length || [401, 403].includes(error?.status ?? 0))
75
+ throw error;
76
+ this.options.onError?.(error, thread);
77
+ return this.reload(thread);
78
+ }
79
+ }
65
80
  if (this.closed)
66
81
  throw new Error("MESSAGE_CLIENT_CLOSED");
67
- const result = await this.fillWindow(thread, limit);
82
+ let result;
83
+ try {
84
+ result = await this.fillWindow(thread, limit);
85
+ }
86
+ catch (error) {
87
+ if ([401, 403].includes(error?.status ?? 0))
88
+ throw error;
89
+ const cached = await this.storage.read(thread, limit);
90
+ if (!cached.messages.length)
91
+ throw error;
92
+ this.options.onError?.(error, thread);
93
+ await this.reload(thread);
94
+ return { messages: cached.messages, hasEarlier: true, complete: false, resetVersion: cached.coverage?.resetVersion ?? 0 };
95
+ }
68
96
  const snapshot = await this.reload(thread);
69
97
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
70
98
  && (typeof navigator === "undefined" || navigator.onLine !== false))
@@ -92,9 +120,10 @@ export class MessageClient {
92
120
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
93
121
  });
94
122
  const old = watch?.snapshot;
95
- if (old && old.hasEarlier === (page.hasEarlier || Boolean(page.coverage?.nextBefore)) && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
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]))
96
125
  return old;
97
- const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
126
+ const snapshot = { messages, resetVersion, complete, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
98
127
  if (!watch) {
99
128
  watch = { snapshot, listeners: new Set() };
100
129
  this.watches.set(thread, watch);
@@ -129,7 +158,10 @@ export class MessageClient {
129
158
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
130
159
  return promise;
131
160
  }
132
- async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt) {
161
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
162
+ const initial = (await this.storage.read(thread, 1)).coverage;
163
+ const version = expectedVersion ?? initial?.resetVersion ?? 0;
164
+ const guard = { version, authoritative: complete === true };
133
165
  let changed = 0;
134
166
  let navigationChanged = false;
135
167
  // Coverage advances only after all bounded batches have durably committed.
@@ -139,7 +171,7 @@ export class MessageClient {
139
171
  return;
140
172
  if (this.closed || this.revoked.has(thread))
141
173
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
142
- changed += await this.storage.commit(thread, batch);
174
+ changed += await this.storage.commit(thread, batch, undefined, guard);
143
175
  batch = [];
144
176
  bytes = 0;
145
177
  };
@@ -155,8 +187,8 @@ export class MessageClient {
155
187
  const old = (await this.storage.read(thread, 1)).coverage;
156
188
  if (this.closed || this.revoked.has(thread))
157
189
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
158
- navigationChanged = nextBefore !== undefined && nextBefore !== old?.nextBefore;
159
- await this.storage.commit(thread, [], { syncedAt: syncedAt ?? old?.syncedAt, complete: complete ?? old?.complete ?? false, checkpoint: checkpoint ?? old?.checkpoint, nextBefore: nextBefore === undefined ? old?.nextBefore : nextBefore });
190
+ navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
191
+ 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);
160
192
  }
161
193
  if (changed || navigationChanged) {
162
194
  await this.reload(thread);
@@ -205,6 +237,7 @@ export class MessageClient {
205
237
  return running;
206
238
  const task = (async () => {
207
239
  const coverage = (await this.storage.read(thread, 1)).coverage;
240
+ let version = coverage?.resetVersion ?? 0;
208
241
  let after = coverage?.complete ? coverage.checkpoint : undefined;
209
242
  let page = after && this.options.transport.changes
210
243
  ? await this.options.transport.changes(thread, after, this.abort.signal)
@@ -212,7 +245,7 @@ export class MessageClient {
212
245
  if (page.resetRequired) {
213
246
  if (this.closed || this.revoked.has(thread))
214
247
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
215
- await this.storage.removeThread(thread);
248
+ version = await this.storage.resetThread(thread);
216
249
  after = undefined;
217
250
  page = await this.options.transport.load(thread, this.abort.signal);
218
251
  }
@@ -221,7 +254,7 @@ export class MessageClient {
221
254
  throw new Error("MESSAGE_ACCESS_REVOKED");
222
255
  if (page.resetRequired)
223
256
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
224
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now());
257
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
225
258
  if (!page.hasMoreChanges)
226
259
  break;
227
260
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -229,7 +262,11 @@ export class MessageClient {
229
262
  after = page.checkpoint;
230
263
  page = await this.options.transport.changes(thread, after, this.abort.signal);
231
264
  }
232
- })().finally(() => { if (this.syncing.get(thread) === task)
265
+ })().catch(async (error) => {
266
+ if (!this.closed && !this.closing && error?.status === 403)
267
+ await this.revoke(thread);
268
+ throw error;
269
+ }).finally(() => { if (this.syncing.get(thread) === task)
233
270
  this.syncing.delete(thread); });
234
271
  this.syncing.set(thread, task);
235
272
  return task;
@@ -245,6 +282,7 @@ export class MessageClient {
245
282
  }
246
283
  async fillWindow(thread, limit, before) {
247
284
  let page = await this.storage.read(thread, limit, before);
285
+ let resets = 0;
248
286
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
249
287
  const pending = this.paging.get(thread);
250
288
  if (pending) {
@@ -252,25 +290,32 @@ export class MessageClient {
252
290
  page = await this.storage.read(thread, limit, before);
253
291
  continue;
254
292
  }
255
- const token = page.coverage.nextBefore;
293
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
256
294
  const task = (async () => {
257
295
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
258
296
  if (this.closed || this.revoked.has(thread))
259
297
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
260
298
  if (remote.resetRequired) {
261
- await this.storage.removeThread(thread);
262
- throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
299
+ if (++resets > 2)
300
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
301
+ await this.storage.resetThread(thread);
302
+ await this.synchronize(thread);
303
+ return;
263
304
  }
264
305
  if (remote.nextBefore === token)
265
306
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
266
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
267
- })().finally(() => { if (this.paging.get(thread) === task)
307
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
308
+ })().catch(async (error) => {
309
+ if (!this.closed && !this.closing && error?.status === 403)
310
+ await this.revoke(thread);
311
+ throw error;
312
+ }).finally(() => { if (this.paging.get(thread) === task)
268
313
  this.paging.delete(thread); });
269
314
  this.paging.set(thread, task);
270
315
  await task;
271
316
  page = await this.storage.read(thread, limit, before);
272
317
  }
273
- return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
318
+ return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
274
319
  }
275
320
  async revoke(thread) {
276
321
  this.revoked.add(thread);
@@ -280,7 +325,7 @@ export class MessageClient {
280
325
  queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
281
326
  this.release(queue);
282
327
  }
283
- await this.storage.removeThread(thread);
328
+ await this.storage.resetThread(thread, true);
284
329
  await this.reload(thread);
285
330
  this.channel?.postMessage(thread);
286
331
  }
@@ -16,6 +16,8 @@ export type MessageCoverage = {
16
16
  complete: boolean;
17
17
  nextBefore?: string | null;
18
18
  syncedAt?: number;
19
+ resetVersion?: number;
20
+ revoked?: boolean;
19
21
  };
20
22
  type CoverageRow = MessageCoverage & {
21
23
  scope: string;
@@ -42,7 +44,12 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
42
44
  hasEarlier: boolean;
43
45
  coverage: CoverageRow | undefined;
44
46
  }>;
45
- commit(thread: string, messages: T[], coverage?: MessageCoverage): Promise<number>;
47
+ commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
48
+ version: number;
49
+ authoritative?: boolean;
50
+ }): Promise<number>;
51
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
52
+ resetThread(thread: string, revoked?: boolean): Promise<number>;
46
53
  removeThread(thread: string): Promise<void>;
47
54
  clear(): Promise<void>;
48
55
  close(): void;
@@ -26,13 +26,18 @@ export class IndexedDbMessageStore {
26
26
  return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
27
27
  });
28
28
  }
29
- async commit(thread, messages, coverage) {
29
+ async commit(thread, messages, coverage, guard) {
30
30
  for (const message of messages)
31
31
  if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
32
32
  throw new Error("MESSAGE_SCOPE_MISMATCH");
33
33
  if (messages.length > 1000)
34
34
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
35
35
  return this.db.transaction("rw", this.rows, this.coverage, async () => {
36
+ const current = await this.coverage.get([this.key, thread]);
37
+ if (guard && (current?.resetVersion ?? 0) !== guard.version)
38
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
39
+ if (guard && current?.revoked && !guard.authoritative)
40
+ throw new Error("MESSAGE_ACCESS_REVOKED");
36
41
  const ids = [...new Set(messages.map(message => message.id))];
37
42
  const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
38
43
  const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
@@ -48,13 +53,23 @@ export class IndexedDbMessageStore {
48
53
  if (changed.size)
49
54
  await this.rows.bulkPut([...changed.values()]);
50
55
  if (coverage) {
51
- const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
56
+ const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
52
57
  if (JSON.stringify(old) !== JSON.stringify(next))
53
58
  await this.coverage.put(next);
54
59
  }
55
60
  return changed.size;
56
61
  });
57
62
  }
63
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
64
+ async resetThread(thread, revoked = false) {
65
+ return this.db.transaction("rw", this.rows, this.coverage, async () => {
66
+ const old = await this.coverage.get([this.key, thread]);
67
+ const resetVersion = (old?.resetVersion ?? 0) + 1;
68
+ await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
69
+ await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
70
+ return resetVersion;
71
+ });
72
+ }
58
73
  async removeThread(thread) {
59
74
  await this.db.transaction("rw", this.rows, this.coverage, async () => {
60
75
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.4",
3
+ "version": "0.12.0-next.5",
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",