@pmate/chat-sdk 0.12.0-next.4 → 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,11 @@ 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 })
149
+ // Explicit full-history queries reuse cached pages and internally rebase expired cursors.
150
+ const history = await chat.getMessages(threadId, { all: true })
146
151
  const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
147
152
  const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
148
153
  await chat.observeMessages(threadId, visibleIds, "visible")
@@ -162,6 +167,11 @@ snapshot reports transport progress and failure. Retry uses the original message
162
167
  hash. The subscribed recent window remains 100 records regardless of individual
163
168
  query limits; history requests return their own pages and do not replace it.
164
169
  Global snapshots contain thread summaries and totals, not complete message arrays.
170
+ Message pages report `complete`; unavailable older pages return cached records with
171
+ `complete: false`. Authorization failures throw and never fall back to denied
172
+ records. Thread snapshots expose `resetVersion`; hosts discard retained rows when
173
+ it changes. Reset epochs are durable, so late cross-tab writes cannot restore a
174
+ pre-reset payload.
165
175
  Voice visibility never acknowledges playback, including observations that arrive
166
176
  before metadata. Create a new instance and close the previous owner when the
167
177
  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> = {
@@ -45,6 +47,8 @@ export declare class ChatSession<T extends StoredMessage> {
45
47
  private globalListeners;
46
48
  private globalSnapshot?;
47
49
  private connectionState;
50
+ private catchUp;
51
+ private readingStarted;
48
52
  private closed;
49
53
  private starting?;
50
54
  private closing?;
@@ -57,7 +61,10 @@ export declare class ChatSession<T extends StoredMessage> {
57
61
  timestamp: number;
58
62
  id: string;
59
63
  };
60
- }): Promise<import("./messageClient.js").MessageSnapshot<T>>;
64
+ all?: boolean;
65
+ refresh?: boolean;
66
+ }): Promise<MessageSnapshot<T>>;
67
+ private readAll;
61
68
  sendMessage(threadId: string, input: {
62
69
  to: string;
63
70
  body: ChatBody;
@@ -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,12 +81,57 @@ 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);
90
+ if (options.all) {
91
+ return this.readAll(threadId);
92
+ }
73
93
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
74
94
  await this.sender.confirm(page.messages.map(message => message.id));
75
- if (!options.before)
76
- this.publish(threadId);
77
95
  return page;
78
96
  }
97
+ async readAll(threadId) {
98
+ for (let attempt = 0; attempt < 3; attempt++) {
99
+ const pages = [];
100
+ let version = 0;
101
+ try {
102
+ let page = await this.getMessages(threadId);
103
+ version = page.resetVersion;
104
+ pages.push(page.messages);
105
+ if (!page.complete)
106
+ return { ...page, hasEarlier: true };
107
+ let restart = false;
108
+ while (page.hasEarlier) {
109
+ const first = page.messages[0];
110
+ if (!first)
111
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
112
+ page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
113
+ if (page.resetVersion !== version) {
114
+ restart = true;
115
+ break;
116
+ }
117
+ pages.push(page.messages);
118
+ }
119
+ if (restart)
120
+ continue;
121
+ return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
122
+ }
123
+ catch (error) {
124
+ if ([401, 403].includes(error?.status ?? 0))
125
+ throw error;
126
+ if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
127
+ continue;
128
+ if (pages.length)
129
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
130
+ throw error;
131
+ }
132
+ }
133
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
134
+ }
79
135
  sendMessage(threadId, input) {
80
136
  this.assertOpen();
81
137
  return this.sender.enqueue(threadId, input);
@@ -99,7 +155,7 @@ export class ChatSession {
99
155
  if (!snapshot) {
100
156
  const messages = this.messages.getSnapshot(threadId);
101
157
  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 } };
158
+ 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
159
  this.snapshots.set(threadId, snapshot);
104
160
  }
105
161
  return snapshot;
@@ -118,7 +174,7 @@ export class ChatSession {
118
174
  this.listeners.set(threadId, listeners);
119
175
  }
120
176
  listeners.add(notify);
121
- const off = this.messages.subscribe(threadId, () => this.publish(threadId));
177
+ const off = this.messages.subscribe(threadId, () => { });
122
178
  return () => {
123
179
  off();
124
180
  listeners.delete(notify);
@@ -166,6 +222,7 @@ export class ChatSession {
166
222
  close() {
167
223
  return this.closing ??= (async () => {
168
224
  this.closed = true;
225
+ this.catchUp.close();
169
226
  this.off.splice(0).forEach(off => off());
170
227
  if (!this.options.sharedConnection)
171
228
  this.connection.close();
@@ -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;
@@ -34,6 +36,7 @@ export declare class MessageClient<T extends StoredMessage> {
34
36
  private options;
35
37
  private storage;
36
38
  private watches;
39
+ private allListeners;
37
40
  private reads;
38
41
  private syncing;
39
42
  private paging;
@@ -54,7 +57,10 @@ export declare class MessageClient<T extends StoredMessage> {
54
57
  constructor(options: MessageClientOptions<T>);
55
58
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
56
59
  subscribe(thread: string, listener: () => void): () => void;
57
- 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>>;
58
64
  /** Application connection/wake events invalidate, rather than fetching directly. */
59
65
  invalidate(thread: string): void;
60
66
  private reload;
@@ -72,8 +78,11 @@ export declare class MessageClient<T extends StoredMessage> {
72
78
  }, limit?: number): Promise<{
73
79
  messages: T[];
74
80
  hasEarlier: boolean;
81
+ resetVersion: number;
82
+ complete: boolean;
75
83
  }>;
76
84
  private fillWindow;
85
+ private denyView;
77
86
  revoke(thread: string): Promise<void>;
78
87
  close(): Promise<void>;
79
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,39 +33,102 @@ 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; }
44
63
  subscribe(thread, listener) {
45
64
  let watch = this.watches.get(thread);
46
65
  if (!watch) {
47
- watch = { snapshot: { messages: [], hasEarlier: false }, listeners: new Set() };
66
+ watch = { snapshot: { messages: [], hasEarlier: false, resetVersion: 0, complete: false }, listeners: new Set() };
48
67
  this.watches.set(thread, watch);
49
68
  }
50
69
  watch.listeners.add(listener);
51
70
  return () => { watch.listeners.delete(listener); };
52
71
  }
53
- read(thread, limit = 100) {
54
- if (this.closed || this.closing || this.revoked.has(thread))
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) {
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)
64
- await this.synchronize(thread);
99
+ if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
100
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
101
+ if (!refresh && local.messages.length && !local.coverage?.revoked)
102
+ return this.reload(thread);
103
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
104
+ }
105
+ this.revoked.delete(thread);
106
+ try {
107
+ await this.synchronize(thread);
108
+ }
109
+ catch (error) {
110
+ if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
111
+ throw error;
112
+ this.options.onError?.(error, thread);
113
+ return this.reload(thread);
114
+ }
115
+ }
65
116
  if (this.closed)
66
117
  throw new Error("MESSAGE_CLIENT_CLOSED");
67
- const result = await this.fillWindow(thread, limit);
118
+ let result;
119
+ try {
120
+ result = await this.fillWindow(thread, limit);
121
+ }
122
+ catch (error) {
123
+ if ([401, 403].includes(error?.status ?? 0))
124
+ throw error;
125
+ const cached = await this.storage.read(thread, limit);
126
+ if (!cached.messages.length)
127
+ 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 };
131
+ }
68
132
  const snapshot = await this.reload(thread);
69
133
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
70
134
  && (typeof navigator === "undefined" || navigator.onLine !== false))
@@ -85,6 +149,10 @@ export class MessageClient {
85
149
  const page = await this.storage.read(thread, limit);
86
150
  if (this.closed)
87
151
  throw new Error("MESSAGE_CLIENT_CLOSED");
152
+ if (this.revoked.has(thread)) {
153
+ page.messages = [];
154
+ page.hasEarlier = false;
155
+ }
88
156
  let watch = this.watches.get(thread);
89
157
  const oldById = new Map(watch?.snapshot.messages.map(message => [message.id, message]));
90
158
  const messages = page.messages.map(message => {
@@ -92,21 +160,26 @@ export class MessageClient {
92
160
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
93
161
  });
94
162
  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]))
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]))
96
166
  return old;
97
- const snapshot = { messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
167
+ const snapshot = { messages, resetVersion, complete, hasEarlier };
98
168
  if (!watch) {
99
169
  watch = { snapshot, listeners: new Set() };
100
170
  this.watches.set(thread, watch);
101
171
  }
102
172
  watch.snapshot = snapshot;
103
173
  watch.listeners.forEach(listener => listener());
174
+ this.allListeners.forEach(listener => listener(thread));
104
175
  return snapshot;
105
176
  }
106
177
  /** Events are visible only after their atomic batch commits. */
107
178
  receive(thread, messages) {
108
- if (this.closed || this.closing || this.revoked.has(thread))
179
+ if (this.closed || this.closing)
109
180
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
181
+ if (this.revoked.has(thread))
182
+ return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
110
183
  const queue = this.writes.get(thread) ?? [];
111
184
  if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
112
185
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
@@ -124,12 +197,15 @@ export class MessageClient {
124
197
  }
125
198
  this.pendingBytes += bytes;
126
199
  this.pendingRecords += messages.length;
127
- 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 }));
128
201
  this.writes.set(thread, queue);
129
202
  this.writeTasks.schedule(thread, () => this.flushWrites(thread));
130
203
  return promise;
131
204
  }
132
- async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt) {
205
+ async persist(thread, messages, checkpoint, complete, nextBefore, syncedAt, expectedVersion) {
206
+ const initial = (await this.storage.read(thread, 1)).coverage;
207
+ const version = expectedVersion ?? initial?.resetVersion ?? 0;
208
+ const guard = { version, authoritative: complete === true };
133
209
  let changed = 0;
134
210
  let navigationChanged = false;
135
211
  // Coverage advances only after all bounded batches have durably committed.
@@ -139,7 +215,7 @@ export class MessageClient {
139
215
  return;
140
216
  if (this.closed || this.revoked.has(thread))
141
217
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
142
- changed += await this.storage.commit(thread, batch);
218
+ changed += await this.storage.commit(thread, batch, undefined, guard);
143
219
  batch = [];
144
220
  bytes = 0;
145
221
  };
@@ -155,8 +231,8 @@ export class MessageClient {
155
231
  const old = (await this.storage.read(thread, 1)).coverage;
156
232
  if (this.closed || this.revoked.has(thread))
157
233
  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 });
234
+ navigationChanged = (nextBefore !== undefined && nextBefore !== old?.nextBefore) || (complete !== undefined && complete !== old?.complete);
235
+ 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
236
  }
161
237
  if (changed || navigationChanged) {
162
238
  await this.reload(thread);
@@ -186,12 +262,26 @@ export class MessageClient {
186
262
  return;
187
263
  this.writes.delete(thread);
188
264
  try {
189
- await this.persist(thread, queue.flatMap(write => write.messages));
190
- queue.forEach(write => write.resolve());
191
- }
192
- catch (error) {
193
- queue.forEach(write => write.reject(error));
194
- 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;
195
285
  }
196
286
  finally {
197
287
  this.release(queue);
@@ -205,6 +295,7 @@ export class MessageClient {
205
295
  return running;
206
296
  const task = (async () => {
207
297
  const coverage = (await this.storage.read(thread, 1)).coverage;
298
+ let version = coverage?.resetVersion ?? 0;
208
299
  let after = coverage?.complete ? coverage.checkpoint : undefined;
209
300
  let page = after && this.options.transport.changes
210
301
  ? await this.options.transport.changes(thread, after, this.abort.signal)
@@ -212,7 +303,7 @@ export class MessageClient {
212
303
  if (page.resetRequired) {
213
304
  if (this.closed || this.revoked.has(thread))
214
305
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
215
- await this.storage.removeThread(thread);
306
+ version = await this.storage.resetThread(thread);
216
307
  after = undefined;
217
308
  page = await this.options.transport.load(thread, this.abort.signal);
218
309
  }
@@ -221,7 +312,7 @@ export class MessageClient {
221
312
  throw new Error("MESSAGE_ACCESS_REVOKED");
222
313
  if (page.resetRequired)
223
314
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
224
- await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now());
315
+ await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version);
225
316
  if (!page.hasMoreChanges)
226
317
  break;
227
318
  if (!page.checkpoint || page.checkpoint === after || !this.options.transport.changes)
@@ -229,7 +320,17 @@ export class MessageClient {
229
320
  after = page.checkpoint;
230
321
  page = await this.options.transport.changes(thread, after, this.abort.signal);
231
322
  }
232
- })().finally(() => { if (this.syncing.get(thread) === task)
323
+ })().catch(async (error) => {
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
+ }
332
+ throw error;
333
+ }).finally(() => { if (this.syncing.get(thread) === task)
233
334
  this.syncing.delete(thread); });
234
335
  this.syncing.set(thread, task);
235
336
  return task;
@@ -245,6 +346,7 @@ export class MessageClient {
245
346
  }
246
347
  async fillWindow(thread, limit, before) {
247
348
  let page = await this.storage.read(thread, limit, before);
349
+ let resets = 0;
248
350
  while (page.messages.length < limit && page.coverage?.nextBefore && this.options.transport.older) {
249
351
  const pending = this.paging.get(thread);
250
352
  if (pending) {
@@ -252,37 +354,68 @@ export class MessageClient {
252
354
  page = await this.storage.read(thread, limit, before);
253
355
  continue;
254
356
  }
255
- const token = page.coverage.nextBefore;
357
+ const token = page.coverage.nextBefore, version = page.coverage.resetVersion ?? 0;
256
358
  const task = (async () => {
257
359
  const remote = await this.options.transport.older(thread, token, this.abort.signal);
258
360
  if (this.closed || this.revoked.has(thread))
259
361
  throw new Error("MESSAGE_CLIENT_UNAVAILABLE");
260
362
  if (remote.resetRequired) {
261
- await this.storage.removeThread(thread);
262
- throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
363
+ if (++resets > 2)
364
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
365
+ await this.storage.resetThread(thread);
366
+ await this.synchronize(thread);
367
+ return;
263
368
  }
264
369
  if (remote.nextBefore === token)
265
370
  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)
371
+ await this.persist(thread, remote.messages, undefined, true, remote.nextBefore ?? null, undefined, version);
372
+ })().catch(async (error) => {
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
+ }
381
+ throw error;
382
+ }).finally(() => { if (this.paging.get(thread) === task)
268
383
  this.paging.delete(thread); });
269
384
  this.paging.set(thread, task);
270
385
  await task;
271
386
  page = await this.storage.read(thread, limit, before);
272
387
  }
273
- return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore) };
388
+ return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
274
389
  }
275
- async revoke(thread) {
390
+ denyView(thread, version) {
276
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);
277
406
  const queue = this.writes.get(thread);
278
407
  this.writes.delete(thread);
279
408
  if (queue) {
280
409
  queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
281
410
  this.release(queue);
282
411
  }
283
- await this.storage.removeThread(thread);
284
- await this.reload(thread);
285
- 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
+ }
286
419
  }
287
420
  async close() {
288
421
  // Flush accepted event batches before closing; new requests are rejected afterwards.
@@ -295,6 +428,7 @@ export class MessageClient {
295
428
  this.abort.abort();
296
429
  this.channel?.close();
297
430
  this.watches.clear();
431
+ this.allListeners.clear();
298
432
  this.storage.close();
299
433
  }
300
434
  }
@@ -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;
@@ -34,6 +36,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
34
36
  indexedDB?: IDBFactory;
35
37
  name?: string;
36
38
  });
39
+ get namespace(): string;
37
40
  read(thread: string, limit?: number, before?: {
38
41
  timestamp: number;
39
42
  id: string;
@@ -42,7 +45,13 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
42
45
  hasEarlier: boolean;
43
46
  coverage: CoverageRow | undefined;
44
47
  }>;
45
- commit(thread: string, messages: T[], coverage?: MessageCoverage): Promise<number>;
48
+ commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
49
+ version: number;
50
+ authoritative?: boolean;
51
+ }): Promise<number>;
52
+ windows(): Promise<CoverageRow[]>;
53
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
54
+ resetThread(thread: string, revoked?: boolean): Promise<number>;
46
55
  removeThread(thread: string): Promise<void>;
47
56
  clear(): Promise<void>;
48
57
  close(): 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");
@@ -26,13 +27,18 @@ export class IndexedDbMessageStore {
26
27
  return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
27
28
  });
28
29
  }
29
- async commit(thread, messages, coverage) {
30
+ async commit(thread, messages, coverage, guard) {
30
31
  for (const message of messages)
31
32
  if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
32
33
  throw new Error("MESSAGE_SCOPE_MISMATCH");
33
34
  if (messages.length > 1000)
34
35
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
35
36
  return this.db.transaction("rw", this.rows, this.coverage, async () => {
37
+ const current = await this.coverage.get([this.key, thread]);
38
+ if (guard && (current?.resetVersion ?? 0) !== guard.version)
39
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
40
+ if (guard && current?.revoked && !guard.authoritative)
41
+ throw new Error("MESSAGE_ACCESS_REVOKED");
36
42
  const ids = [...new Set(messages.map(message => message.id))];
37
43
  const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
38
44
  const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
@@ -48,13 +54,26 @@ export class IndexedDbMessageStore {
48
54
  if (changed.size)
49
55
  await this.rows.bulkPut([...changed.values()]);
50
56
  if (coverage) {
51
- const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
57
+ const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
52
58
  if (JSON.stringify(old) !== JSON.stringify(next))
53
59
  await this.coverage.put(next);
54
60
  }
55
61
  return changed.size;
56
62
  });
57
63
  }
64
+ async windows() {
65
+ return this.coverage.where("scope").equals(this.key).toArray();
66
+ }
67
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
68
+ async resetThread(thread, revoked = false) {
69
+ return this.db.transaction("rw", this.rows, this.coverage, async () => {
70
+ const old = await this.coverage.get([this.key, thread]);
71
+ const resetVersion = (old?.resetVersion ?? 0) + 1;
72
+ await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
73
+ await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
74
+ return resetVersion;
75
+ });
76
+ }
58
77
  async removeThread(thread) {
59
78
  await this.db.transaction("rw", this.rows, this.coverage, async () => {
60
79
  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.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",