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

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)))
@@ -198,6 +201,11 @@ legacy cleanup happens after durable migration. Normal reads use memory and writ
198
201
  are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
199
202
  the host, and pending operations are not reported as saved before their promise
200
203
  resolves.
204
+ Control-event batches are bounded by both 128 records and 1 MiB per transaction;
205
+ the queue includes in-flight writes and is limited to 4096 records / 8 MiB.
206
+ `transactionBytes` and `queueBytes` can lower these limits. Oversized operations
207
+ fail before changing the memory snapshot. Repeating an in-flight write waits for
208
+ that physical write, including its failure, rather than acknowledging it early.
201
209
 
202
210
  `DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
203
211
  before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
@@ -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 () => {
@@ -5,6 +5,8 @@ export type RecordCacheOptions<T> = {
5
5
  indexedDB?: IDBFactory;
6
6
  name?: string;
7
7
  onError?: (error: unknown) => void;
8
+ transactionBytes?: number;
9
+ queueBytes?: number;
8
10
  };
9
11
  /** In-memory control-event index backed by bounded asynchronous IndexedDB batches.
10
12
  * Legacy storage is read only during open; the hot path never uses localStorage. */
@@ -23,6 +25,11 @@ export declare class IndexedDbRecordCache<T> {
23
25
  private closing;
24
26
  private initialized;
25
27
  private running?;
28
+ private activeBytes;
29
+ private pendingBytes;
30
+ private activeRecords;
31
+ private transactionBytes;
32
+ private queueBytes;
26
33
  private channel?;
27
34
  private listeners;
28
35
  constructor(options: RecordCacheOptions<T>);
@@ -34,7 +41,9 @@ export declare class IndexedDbRecordCache<T> {
34
41
  subscribe(listener: () => void): () => void;
35
42
  private reload;
36
43
  upsert(incoming: T[]): Promise<void>;
44
+ private bytes;
37
45
  private write;
46
+ private writeBatch;
38
47
  flush(): Promise<void>;
39
48
  private runFlush;
40
49
  close(): Promise<void>;
@@ -17,17 +17,26 @@ export class IndexedDbRecordCache {
17
17
  closing = false;
18
18
  initialized = false;
19
19
  running;
20
+ activeBytes = 0;
21
+ pendingBytes = 0;
22
+ activeRecords = 0;
23
+ transactionBytes;
24
+ queueBytes;
20
25
  channel;
21
26
  listeners = new Set();
22
27
  constructor(options) {
23
28
  this.options = options;
29
+ this.transactionBytes = options.transactionBytes ?? 1_048_576;
30
+ this.queueBytes = options.queueBytes ?? 8_388_608;
31
+ if (!Number.isSafeInteger(this.transactionBytes) || this.transactionBytes < 1 || !Number.isSafeInteger(this.queueBytes) || this.queueBytes < this.transactionBytes)
32
+ throw new Error("INVALID_RECORD_CACHE_BYTE_LIMIT");
24
33
  this.db = new Dexie(options.name ?? "pmate-chat-records", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
25
34
  this.db.version(1).stores({ records: "[scope+id],scope", metadata: "scope" });
26
35
  this.records = this.db.table("records");
27
36
  this.metadata = this.db.table("metadata");
28
37
  this.tasks = new WindowedTasks(16, error => options.onError?.(error));
29
38
  if (typeof BroadcastChannel !== "undefined") {
30
- this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}`);
39
+ this.channel = new BroadcastChannel(`pmate-chat-records:${options.scope}${this.db.name === "pmate-chat-records" ? "" : `:${this.db.name}`}`);
31
40
  this.channel.onmessage = () => this.tasks.schedule("reload", () => this.reload());
32
41
  }
33
42
  }
@@ -83,30 +92,70 @@ export class IndexedDbRecordCache {
83
92
  await this.ready;
84
93
  if (this.closed || this.closing)
85
94
  throw new Error("RECORD_CACHE_CLOSED");
86
- if (incoming.length + this.pending.size > 4096)
95
+ if (incoming.length > 4096)
87
96
  throw new Error("RECORD_CACHE_BACKPRESSURE");
88
- let changed = false;
97
+ // Plan and validate the whole operation before publishing any in-memory state.
98
+ const staged = new Map();
89
99
  for (const value of incoming) {
90
- const key = this.options.key(value), old = this.values.get(key), next = this.options.merge(old, value);
100
+ const key = this.options.key(value), old = staged.get(key) ?? this.values.get(key);
101
+ const next = this.options.merge(old, value);
91
102
  if (JSON.stringify(old) === JSON.stringify(next))
92
103
  continue;
104
+ if (this.bytes(next) > this.transactionBytes)
105
+ throw new Error("RECORD_CACHE_RECORD_TOO_LARGE");
106
+ staged.set(key, structuredClone(next));
107
+ }
108
+ let nextBytes = this.pendingBytes, nextRecords = this.pending.size;
109
+ for (const [key, value] of staged) {
110
+ const previous = this.pending.get(key);
111
+ nextBytes += this.bytes(value) - (previous === undefined ? 0 : this.bytes(previous));
112
+ if (!this.pending.has(key))
113
+ nextRecords++;
114
+ }
115
+ if (nextRecords + this.activeRecords > 4096 || nextBytes + this.activeBytes > this.queueBytes)
116
+ throw new Error("RECORD_CACHE_BACKPRESSURE");
117
+ let changed = false;
118
+ for (const [key, next] of staged) {
93
119
  this.values.set(key, next);
94
120
  this.pending.set(key, next);
95
121
  changed = true;
96
122
  }
123
+ this.pendingBytes = nextBytes;
97
124
  if (changed)
98
125
  this.snapshotValue = [...this.values.values()];
99
- if (!changed && !this.pending.size)
126
+ if (!changed && !this.pending.size) {
127
+ await this.running;
100
128
  return;
129
+ }
101
130
  const committed = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
102
131
  this.tasks.schedule("write", () => this.flush());
103
132
  return committed;
104
133
  }
134
+ bytes(value) { return new TextEncoder().encode(JSON.stringify({ scope: this.options.scope, id: this.options.key(value), value })).byteLength; }
105
135
  async write(values) {
136
+ let batch = [], bytes = 0;
137
+ for (const value of values) {
138
+ const size = this.bytes(value);
139
+ if (size > this.transactionBytes)
140
+ throw new Error("RECORD_CACHE_RECORD_TOO_LARGE");
141
+ if (batch.length && (batch.length === 128 || bytes + size > this.transactionBytes)) {
142
+ await this.writeBatch(batch);
143
+ batch = [];
144
+ bytes = 0;
145
+ }
146
+ batch.push(value);
147
+ bytes += size;
148
+ }
149
+ if (batch.length)
150
+ await this.writeBatch(batch);
151
+ }
152
+ async writeBatch(values) {
106
153
  await this.db.transaction("rw", this.records, async () => {
107
154
  const previous = await this.records.bulkGet(values.map(value => [this.options.scope, this.options.key(value)]));
108
155
  const records = values.map((value, index) => ({ scope: this.options.scope, id: this.options.key(value), value: this.options.merge(previous[index]?.value, value) }));
109
156
  const changed = records.filter((row, index) => JSON.stringify(previous[index]?.value) !== JSON.stringify(row.value));
157
+ if (changed.reduce((sum, row) => sum + this.bytes(row.value), 0) > this.transactionBytes)
158
+ throw new Error("RECORD_CACHE_MERGE_TOO_LARGE");
110
159
  if (changed.length)
111
160
  await this.records.bulkPut(changed);
112
161
  });
@@ -126,8 +175,11 @@ export class IndexedDbRecordCache {
126
175
  async runFlush() {
127
176
  const values = [...this.pending.values()], waiters = this.waiters.splice(0);
128
177
  this.pending.clear();
178
+ this.pendingBytes = 0;
129
179
  if (!values.length)
130
180
  return;
181
+ this.activeBytes = values.reduce((sum, value) => sum + this.bytes(value), 0);
182
+ this.activeRecords = values.length;
131
183
  try {
132
184
  for (let offset = 0; offset < values.length; offset += 128)
133
185
  await this.write(values.slice(offset, offset + 128));
@@ -142,9 +194,14 @@ export class IndexedDbRecordCache {
142
194
  const key = this.options.key(value);
143
195
  this.pending.set(key, this.options.merge(value, this.pending.get(key) ?? value));
144
196
  }
197
+ this.pendingBytes = [...this.pending.values()].reduce((sum, value) => sum + this.bytes(value), 0);
145
198
  waiters.forEach(waiter => waiter.reject(error));
146
199
  throw error;
147
200
  }
201
+ finally {
202
+ this.activeBytes = 0;
203
+ this.activeRecords = 0;
204
+ }
148
205
  }
149
206
  async close() {
150
207
  this.closing = true;
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.7",
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",