@pmate/chat-sdk 0.12.0-next.3 → 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,11 +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));
79
+ await this.sender.confirm(page.messages.map(message => message.id));
74
80
  if (!options.before)
75
81
  this.publish(threadId);
76
82
  return page;
77
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
+ }
78
122
  sendMessage(threadId, input) {
79
123
  this.assertOpen();
80
124
  return this.sender.enqueue(threadId, input);
@@ -98,7 +142,7 @@ export class ChatSession {
98
142
  if (!snapshot) {
99
143
  const messages = this.messages.getSnapshot(threadId);
100
144
  const state = this.reading.getSnapshot();
101
- 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 } };
102
146
  this.snapshots.set(threadId, snapshot);
103
147
  }
104
148
  return snapshot;
@@ -143,10 +143,13 @@ export class DurableChatSender {
143
143
  /** Confirm only after the authoritative message has been durably merged.
144
144
  * A tombstone prevents another tab's late failure from recreating the outbox. */
145
145
  async confirm(hashes) {
146
+ hashes = [...new Set(hashes)];
147
+ if (!hashes.length)
148
+ return;
146
149
  await this.ready;
147
150
  await this.db.transaction("rw", this.outbox, async () => {
148
151
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
149
- const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
152
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
150
153
  if (confirmed.length)
151
154
  await this.outbox.bulkPut(confirmed);
152
155
  });
@@ -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;
@@ -27,6 +29,7 @@ export type MessageClientOptions<T extends StoredMessage> = {
27
29
  syncWindowMs?: number;
28
30
  transactionBytes?: number;
29
31
  queueBytes?: number;
32
+ freshnessMs?: number;
30
33
  };
31
34
  /** Local-first message facade; hosts never select a cache or network source. */
32
35
  export declare class MessageClient<T extends StoredMessage> {
@@ -71,6 +74,8 @@ export declare class MessageClient<T extends StoredMessage> {
71
74
  }, limit?: number): Promise<{
72
75
  messages: T[];
73
76
  hasEarlier: boolean;
77
+ resetVersion: number;
78
+ complete: boolean;
74
79
  }>;
75
80
  private fillWindow;
76
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,12 +60,43 @@ 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);
97
+ if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
98
+ && (typeof navigator === "undefined" || navigator.onLine !== false))
99
+ this.invalidate(thread);
69
100
  return limit === 100 ? snapshot : result;
70
101
  })().finally(() => { if (this.reads.get(key) === task)
71
102
  this.reads.delete(key); });
@@ -89,9 +120,10 @@ export class MessageClient {
89
120
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
90
121
  });
91
122
  const old = watch?.snapshot;
92
- 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]))
93
125
  return old;
94
- const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
126
+ const snapshot = { messages, resetVersion, complete, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
95
127
  if (!watch) {
96
128
  watch = { snapshot, listeners: new Set() };
97
129
  this.watches.set(thread, watch);
@@ -105,17 +137,20 @@ export class MessageClient {
105
137
  if (this.closed || this.closing || this.revoked.has(thread))
106
138
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
107
139
  const queue = this.writes.get(thread) ?? [];
140
+ if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
141
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
108
142
  let bytes = 0;
109
143
  try {
110
- for (const message of messages)
144
+ for (const message of messages) {
111
145
  bytes += this.messageBytes(message);
146
+ if (this.pendingBytes + bytes > this.queueBytes)
147
+ throw new Error("MESSAGE_STORAGE_BACKPRESSURE");
148
+ }
112
149
  messages = structuredClone(messages);
113
150
  }
114
151
  catch (error) {
115
152
  return Promise.reject(error);
116
153
  }
117
- if (messages.length > 1000 || this.pendingRecords + messages.length > 4096 || this.pendingBytes + bytes > this.queueBytes)
118
- return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
119
154
  this.pendingBytes += bytes;
120
155
  this.pendingRecords += messages.length;
121
156
  const promise = new Promise((resolve, reject) => queue.push({ messages, bytes, resolve, reject }));
@@ -123,8 +158,12 @@ export class MessageClient {
123
158
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
124
159
  return promise;
125
160
  }
126
- async persist(thread, messages, checkpoint, complete, nextBefore) {
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 };
127
165
  let changed = 0;
166
+ let navigationChanged = false;
128
167
  // Coverage advances only after all bounded batches have durably committed.
129
168
  let batch = [], bytes = 0;
130
169
  const commit = async () => {
@@ -132,7 +171,7 @@ export class MessageClient {
132
171
  return;
133
172
  if (this.closed || this.revoked.has(thread))
134
173
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
135
- changed += await this.storage.commit(thread, batch);
174
+ changed += await this.storage.commit(thread, batch, undefined, guard);
136
175
  batch = [];
137
176
  bytes = 0;
138
177
  };
@@ -148,9 +187,10 @@ export class MessageClient {
148
187
  const old = (await this.storage.read(thread, 1)).coverage;
149
188
  if (this.closed || this.revoked.has(thread))
150
189
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
151
- await this.storage.commit(thread, [], { 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);
152
192
  }
153
- if (changed) {
193
+ if (changed || navigationChanged) {
154
194
  await this.reload(thread);
155
195
  this.channel?.postMessage(thread);
156
196
  }
@@ -197,6 +237,7 @@ export class MessageClient {
197
237
  return running;
198
238
  const task = (async () => {
199
239
  const coverage = (await this.storage.read(thread, 1)).coverage;
240
+ let version = coverage?.resetVersion ?? 0;
200
241
  let after = coverage?.complete ? coverage.checkpoint : undefined;
201
242
  let page = after && this.options.transport.changes
202
243
  ? await this.options.transport.changes(thread, after, this.abort.signal)
@@ -204,7 +245,7 @@ export class MessageClient {
204
245
  if (page.resetRequired) {
205
246
  if (this.closed || this.revoked.has(thread))
206
247
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
207
- await this.storage.removeThread(thread);
248
+ version = await this.storage.resetThread(thread);
208
249
  after = undefined;
209
250
  page = await this.options.transport.load(thread, this.abort.signal);
210
251
  }
@@ -213,7 +254,7 @@ export class MessageClient {
213
254
  throw new Error("MESSAGE_ACCESS_REVOKED");
214
255
  if (page.resetRequired)
215
256
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
216
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore);
257
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
217
258
  if (!page.hasMoreChanges)
218
259
  break;
219
260
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -221,7 +262,11 @@ export class MessageClient {
221
262
  after = page.checkpoint;
222
263
  page = await this.options.transport.changes(thread, after, this.abort.signal);
223
264
  }
224
- })().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)
225
270
  this.syncing.delete(thread); });
226
271
  this.syncing.set(thread, task);
227
272
  return task;
@@ -237,6 +282,7 @@ export class MessageClient {
237
282
  }
238
283
  async fillWindow(thread, limit, before) {
239
284
  let page = await this.storage.read(thread, limit, before);
285
+ let resets = 0;
240
286
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
241
287
  const pending = this.paging.get(thread);
242
288
  if (pending) {
@@ -244,25 +290,32 @@ export class MessageClient {
244
290
  page = await this.storage.read(thread, limit, before);
245
291
  continue;
246
292
  }
247
- const token = page.coverage.nextBefore;
293
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
248
294
  const task = (async () => {
249
295
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
250
296
  if (this.closed || this.revoked.has(thread))
251
297
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
252
298
  if (remote.resetRequired) {
253
- await this.storage.removeThread(thread);
254
- 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;
255
304
  }
256
305
  if (remote.nextBefore === token)
257
306
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
258
- await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null);
259
- })().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)
260
313
  this.paging.delete(thread); });
261
314
  this.paging.set(thread, task);
262
315
  await task;
263
316
  page = await this.storage.read(thread, limit, before);
264
317
  }
265
- 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 };
266
319
  }
267
320
  async revoke(thread) {
268
321
  this.revoked.add(thread);
@@ -272,7 +325,7 @@ export class MessageClient {
272
325
  queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
273
326
  this.release(queue);
274
327
  }
275
- await this.storage.removeThread(thread);
328
+ await this.storage.resetThread(thread, true);
276
329
  await this.reload(thread);
277
330
  this.channel?.postMessage(thread);
278
331
  }
@@ -15,6 +15,9 @@ export type MessageCoverage = {
15
15
  checkpoint?: string;
16
16
  complete: boolean;
17
17
  nextBefore?: string | null;
18
+ syncedAt?: number;
19
+ resetVersion?: number;
20
+ revoked?: boolean;
18
21
  };
19
22
  type CoverageRow = MessageCoverage & {
20
23
  scope: string;
@@ -41,7 +44,12 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
41
44
  hasEarlier: boolean;
42
45
  coverage: CoverageRow | undefined;
43
46
  }>;
44
- 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>;
45
53
  removeThread(thread: string): Promise<void>;
46
54
  clear(): Promise<void>;
47
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.3",
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",