@pmate/chat-sdk 0.12.0-next.18 → 0.12.0-next.19

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.
@@ -71,6 +71,8 @@ export declare class ChatSession<T extends StoredMessage> {
71
71
  /** Initializes local state and background reading sync without opening a
72
72
  * second transport when the host already owns calls/presence. */
73
73
  initialize(): Promise<void>;
74
+ /** One-time migration only: never establishes trusted history coverage. */
75
+ importLegacyMessages(threadId: string, messages: T[]): Promise<void>;
74
76
  getReadingSnapshot(): import("./readIndex.js").ReadIndexState | undefined;
75
77
  subscribeReading(listener: () => void): () => void;
76
78
  refreshReading(): void;
@@ -117,6 +117,8 @@ export class ChatSession {
117
117
  this.assertOpen();
118
118
  })().catch(error => { this.initializing = undefined; throw error; });
119
119
  }
120
+ /** One-time migration only: never establishes trusted history coverage. */
121
+ importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
120
122
  getReadingSnapshot() { return this.reading.getSnapshot(); }
121
123
  subscribeReading(listener) { this.assertOpen(); return this.reading.subscribe(listener); }
122
124
  refreshReading() { this.assertOpen(); this.reading.refresh(); }
@@ -196,6 +198,8 @@ export class ChatSession {
196
198
  break;
197
199
  }
198
200
  pages.push(page.messages);
201
+ if (!page.complete)
202
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
199
203
  }
200
204
  if (restart)
201
205
  continue;
@@ -54,6 +54,7 @@ export declare class MessageClient<T extends StoredMessage> {
54
54
  private queueBytes;
55
55
  private pendingBytes;
56
56
  private pendingRecords;
57
+ private legacyImports;
57
58
  constructor(options: MessageClientOptions<T>);
58
59
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
59
60
  subscribe(thread: string, listener: () => void): () => void;
@@ -63,6 +64,9 @@ export declare class MessageClient<T extends StoredMessage> {
63
64
  read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
64
65
  /** Application connection/wake events invalidate, rather than fetching directly. */
65
66
  invalidate(thread: string): void;
67
+ private fallback;
68
+ /** Migration batches remain separate from authoritative message coverage. */
69
+ importLegacy(thread: string, messages: T[]): Promise<void>;
66
70
  private reload;
67
71
  /** Events are visible only after their atomic batch commits. */
68
72
  receive(thread: string, messages: T[]): Promise<void>;
@@ -75,12 +79,7 @@ export declare class MessageClient<T extends StoredMessage> {
75
79
  earlier(thread: string, before: {
76
80
  timestamp: number;
77
81
  id: string;
78
- }, limit?: number): Promise<{
79
- messages: T[];
80
- hasEarlier: boolean;
81
- resetVersion: number;
82
- complete: boolean;
83
- }>;
82
+ }, limit?: number): Promise<MessageSnapshot<T>>;
84
83
  private fillWindow;
85
84
  private denyView;
86
85
  revoke(thread: string): Promise<void>;
@@ -23,6 +23,7 @@ export class MessageClient {
23
23
  queueBytes;
24
24
  pendingBytes = 0;
25
25
  pendingRecords = 0;
26
+ legacyImports = 0;
26
27
  constructor(options) {
27
28
  this.options = options;
28
29
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
@@ -53,6 +54,11 @@ export class MessageClient {
53
54
  options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
54
55
  })();
55
56
  }
57
+ else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
58
+ const thread = event.data.thread;
59
+ void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
60
+ options.onError?.(error, thread); });
61
+ }
56
62
  else if (typeof event.data === "string" && this.watches.has(event.data))
57
63
  void this.reload(event.data).catch(error => { if (!this.closed)
58
64
  options.onError?.(error, event.data); });
@@ -98,8 +104,11 @@ export class MessageClient {
98
104
  const local = await this.storage.read(thread, limit);
99
105
  if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
100
106
  if (typeof navigator !== "undefined" && navigator.onLine === false) {
101
- if (!refresh && local.messages.length && !local.coverage?.revoked)
102
- return this.reload(thread);
107
+ if (!refresh && !local.coverage?.revoked) {
108
+ const fallback = await this.fallback(thread, limit);
109
+ if (fallback.messages.length)
110
+ return fallback;
111
+ }
103
112
  throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
104
113
  }
105
114
  this.revoked.delete(thread);
@@ -107,10 +116,13 @@ export class MessageClient {
107
116
  await this.synchronize(thread);
108
117
  }
109
118
  catch (error) {
110
- if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
119
+ if (refresh || [401, 403].includes(error?.status ?? 0))
120
+ throw error;
121
+ const fallback = await this.fallback(thread, limit);
122
+ if (!fallback.messages.length)
111
123
  throw error;
112
124
  this.options.onError?.(error, thread);
113
- return this.reload(thread);
125
+ return fallback;
114
126
  }
115
127
  }
116
128
  if (this.closed)
@@ -122,12 +134,11 @@ export class MessageClient {
122
134
  catch (error) {
123
135
  if ([401, 403].includes(error?.status ?? 0))
124
136
  throw error;
125
- const cached = await this.storage.read(thread, limit);
137
+ const cached = await this.fallback(thread, limit);
126
138
  if (!cached.messages.length)
127
139
  throw error;
128
140
  this.options.onError?.(error, thread);
129
- await this.reload(thread);
130
- return { messages: cached.messages, hasEarlier: true, complete: false, resetVersion: cached.coverage?.resetVersion ?? 0 };
141
+ return cached;
131
142
  }
132
143
  const snapshot = await this.reload(thread);
133
144
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
@@ -144,9 +155,45 @@ export class MessageClient {
144
155
  if (!this.closed && !this.closing && !this.revoked.has(thread))
145
156
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
146
157
  }
147
- async reload(thread) {
158
+ async fallback(thread, limit, before) {
159
+ const page = await this.storage.readFallback(thread, limit, before);
160
+ if (!before && page.messages.length)
161
+ await this.reload(thread, true);
162
+ return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
163
+ }
164
+ /** Migration batches remain separate from authoritative message coverage. */
165
+ importLegacy(thread, messages) {
166
+ if (this.closed || this.closing)
167
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
168
+ if (messages.length > 128)
169
+ return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
170
+ if (this.legacyImports >= 4)
171
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
172
+ let records;
173
+ try {
174
+ let bytes = 0;
175
+ for (const message of messages) {
176
+ bytes += this.messageBytes(message);
177
+ if (bytes > Math.min(this.transactionBytes, 1_048_576))
178
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
179
+ }
180
+ records = structuredClone(messages);
181
+ }
182
+ catch (error) {
183
+ return Promise.reject(error);
184
+ }
185
+ this.legacyImports++;
186
+ const task = this.storage.importLegacy(thread, records).then(async () => {
187
+ const page = await this.storage.read(thread, 1);
188
+ await this.reload(thread, !page.coverage?.complete);
189
+ this.channel?.postMessage({ type: "legacy", thread });
190
+ }).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
191
+ this.activeWrites.add(task);
192
+ return task;
193
+ }
194
+ async reload(thread, fallback = false) {
148
195
  const limit = 100;
149
- const page = await this.storage.read(thread, limit);
196
+ const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
150
197
  if (this.closed)
151
198
  throw new Error("MESSAGE_CLIENT_CLOSED");
152
199
  if (this.revoked.has(thread)) {
@@ -160,7 +207,7 @@ export class MessageClient {
160
207
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
161
208
  });
162
209
  const old = watch?.snapshot;
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);
210
+ const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
164
211
  const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
165
212
  if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
166
213
  return old;
@@ -340,9 +387,23 @@ export class MessageClient {
340
387
  throw new Error("MESSAGE_CLIENT_CLOSED");
341
388
  if (this.revoked.has(thread))
342
389
  throw new Error("MESSAGE_ACCESS_REVOKED");
343
- if (!(await this.storage.read(thread, 1)).coverage?.complete)
344
- await this.synchronize(thread);
345
- return this.fillWindow(thread, limit, before);
390
+ try {
391
+ if (!(await this.storage.read(thread, 1)).coverage?.complete) {
392
+ if (typeof navigator !== "undefined" && navigator.onLine === false)
393
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
394
+ await this.synchronize(thread);
395
+ }
396
+ return await this.fillWindow(thread, limit, before);
397
+ }
398
+ catch (error) {
399
+ if ([401, 403].includes(error?.status ?? 0))
400
+ throw error;
401
+ const fallback = await this.fallback(thread, limit, before);
402
+ if (!fallback.messages.length)
403
+ throw error;
404
+ this.options.onError?.(error, thread);
405
+ return fallback;
406
+ }
346
407
  }
347
408
  async fillWindow(thread, limit, before) {
348
409
  let page = await this.storage.read(thread, limit, before);
@@ -30,6 +30,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
30
30
  private merge;
31
31
  private db;
32
32
  private rows;
33
+ private legacy;
33
34
  private coverage;
34
35
  private key;
35
36
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
@@ -45,6 +46,17 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
45
46
  hasEarlier: boolean;
46
47
  coverage: CoverageRow | undefined;
47
48
  }>;
49
+ /** Migration records never claim coverage or overwrite authoritative rows. */
50
+ importLegacy(thread: string, messages: T[]): Promise<number>;
51
+ /** Bounded fallback only; callers must report an incomplete window. */
52
+ readFallback(thread: string, limit?: number, before?: {
53
+ timestamp: number;
54
+ id: string;
55
+ }): Promise<{
56
+ messages: T[];
57
+ hasEarlier: boolean;
58
+ coverage: CoverageRow | undefined;
59
+ }>;
48
60
  commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
49
61
  version: number;
50
62
  authoritative?: boolean;
@@ -6,6 +6,7 @@ export class IndexedDbMessageStore {
6
6
  merge;
7
7
  db;
8
8
  rows;
9
+ legacy;
9
10
  coverage;
10
11
  key;
11
12
  constructor(scope, merge, options = {}) {
@@ -13,6 +14,8 @@ export class IndexedDbMessageStore {
13
14
  this.key = messageScopeKey(scope);
14
15
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
15
16
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
17
+ this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
18
+ this.legacy = this.db.table("legacyMessages");
16
19
  this.rows = this.db.table("messages");
17
20
  this.coverage = this.db.table("coverage");
18
21
  this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
@@ -27,32 +30,87 @@ export class IndexedDbMessageStore {
27
30
  return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
28
31
  });
29
32
  }
33
+ /** Migration records never claim coverage or overwrite authoritative rows. */
34
+ async importLegacy(thread, messages) {
35
+ if (messages.length > 128)
36
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
37
+ let bytes = 0;
38
+ for (const message of messages) {
39
+ if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
40
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
41
+ bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
42
+ if (bytes > 1_048_576)
43
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
44
+ }
45
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
46
+ const coverage = await this.coverage.get([this.key, thread]);
47
+ if (coverage?.revoked)
48
+ throw new Error("MESSAGE_ACCESS_REVOKED");
49
+ if ((coverage?.resetVersion ?? 0) > 0)
50
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
51
+ const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
52
+ const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
53
+ const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
54
+ const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
55
+ for (const message of messages) {
56
+ if (known.has(message.id))
57
+ continue;
58
+ const old = latest.get(message.id), value = this.merge(old, message);
59
+ if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
60
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
61
+ latest.set(message.id, value);
62
+ if (JSON.stringify(old) !== JSON.stringify(value))
63
+ changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
64
+ }
65
+ if (changed.size)
66
+ await this.legacy.bulkPut([...changed.values()]);
67
+ return changed.size;
68
+ });
69
+ }
70
+ /** Bounded fallback only; callers must report an incomplete window. */
71
+ async readFallback(thread, limit = 100, before) {
72
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
73
+ throw new Error("INVALID_MESSAGE_LIMIT");
74
+ return this.db.transaction("r", this.rows, this.legacy, this.coverage, async () => {
75
+ const coverage = await this.coverage.get([this.key, thread]);
76
+ if (coverage?.revoked)
77
+ return { messages: [], hasEarlier: false, coverage };
78
+ const read = (table) => table.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
79
+ const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
80
+ const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
81
+ return { messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage };
82
+ });
83
+ }
30
84
  async commit(thread, messages, coverage, guard) {
31
85
  for (const message of messages)
32
86
  if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
33
87
  throw new Error("MESSAGE_SCOPE_MISMATCH");
34
88
  if (messages.length > 1000)
35
89
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
36
- return this.db.transaction("rw", this.rows, this.coverage, async () => {
90
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
37
91
  const current = await this.coverage.get([this.key, thread]);
38
92
  if (guard && (current?.resetVersion ?? 0) !== guard.version)
39
93
  throw new Error("MESSAGE_PROJECTION_CHANGED");
40
94
  if (guard && current?.revoked && !guard.authoritative)
41
95
  throw new Error("MESSAGE_ACCESS_REVOKED");
42
96
  const ids = [...new Set(messages.map(message => message.id))];
43
- const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
44
- const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
97
+ const keys = ids.map(id => [this.key, thread, id]);
98
+ const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
99
+ const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
100
+ const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
45
101
  const changed = new Map();
46
102
  for (const message of messages) {
47
103
  const old = latest.get(message.id), value = this.merge(old, message);
48
104
  if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
49
105
  throw new Error("MESSAGE_SCOPE_MISMATCH");
50
106
  latest.set(message.id, value);
51
- if (JSON.stringify(old) !== JSON.stringify(value))
107
+ if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
52
108
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
53
109
  }
54
110
  if (changed.size)
55
111
  await this.rows.bulkPut([...changed.values()]);
112
+ if (ids.length)
113
+ await this.legacy.bulkDelete(ids.map(id => [this.key, thread, id]));
56
114
  if (coverage) {
57
115
  const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
58
116
  if (JSON.stringify(old) !== JSON.stringify(next))
@@ -66,22 +124,25 @@ export class IndexedDbMessageStore {
66
124
  }
67
125
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
68
126
  async resetThread(thread, revoked = false) {
69
- return this.db.transaction("rw", this.rows, this.coverage, async () => {
127
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
70
128
  const old = await this.coverage.get([this.key, thread]);
71
129
  const resetVersion = (old?.resetVersion ?? 0) + 1;
72
130
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
131
+ await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
73
132
  await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
74
133
  return resetVersion;
75
134
  });
76
135
  }
77
136
  async removeThread(thread) {
78
- await this.db.transaction("rw", this.rows, this.coverage, async () => {
137
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
79
138
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
139
+ await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
80
140
  await this.coverage.delete([this.key, thread]);
81
141
  });
82
142
  }
83
143
  async clear() {
84
- await this.db.transaction("rw", this.rows, this.coverage, async () => {
144
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
145
+ await this.legacy.where("scope").equals(this.key).delete();
85
146
  await this.rows.where("scope").equals(this.key).delete();
86
147
  await this.coverage.where("scope").equals(this.key).delete();
87
148
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.18",
3
+ "version": "0.12.0-next.19",
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",