@pmate/chat-sdk 0.12.0-next.25 → 0.12.0-next.26

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.
@@ -100,12 +100,20 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
100
100
  private migrateLegacy;
101
101
  private database;
102
102
  private profiles;
103
+ private threadRows;
104
+ private messageRows;
105
+ private positionRows;
106
+ private normalizedStates;
103
107
  private versions;
104
108
  private closed;
105
109
  constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
106
110
  private transaction;
107
111
  private key;
112
+ private hydrate;
108
113
  private loadCurrent;
114
+ /** Message bodies are immutable for local read observations: keep them out of
115
+ * profile/cursor writes, and update only changed normalized rows on sync. */
116
+ private persist;
109
117
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
110
118
  loadVersioned(appId: string, profileId: string): Promise<{
111
119
  state: ReadIndexState | undefined;
@@ -190,6 +190,10 @@ export class IndexedDbReadIndex {
190
190
  migrateLegacy;
191
191
  database;
192
192
  profiles;
193
+ threadRows;
194
+ messageRows;
195
+ positionRows;
196
+ normalizedStates = new WeakSet();
193
197
  versions;
194
198
  closed = false;
195
199
  constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
@@ -201,6 +205,10 @@ export class IndexedDbReadIndex {
201
205
  // The empty schema retains the legacy out-of-line primary key and records.
202
206
  this.database.version(1).stores({ profiles: "" });
203
207
  this.database.version(2).stores({ profiles: "", versions: "" });
208
+ this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
209
+ this.positionRows = this.database.table("readPositions");
210
+ this.threadRows = this.database.table("readThreads");
211
+ this.messageRows = this.database.table("readMessages");
204
212
  this.profiles = this.database.table("profiles");
205
213
  this.versions = this.database.table("versions");
206
214
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
@@ -211,7 +219,7 @@ export class IndexedDbReadIndex {
211
219
  throw new Error("READ_STORAGE_CLOSED");
212
220
  let started = false;
213
221
  try {
214
- return await this.database.transaction(mode, this.profiles, this.versions, async () => {
222
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
215
223
  if (this.closed)
216
224
  throw new Error("READ_STORAGE_CLOSED");
217
225
  started = true;
@@ -230,25 +238,136 @@ export class IndexedDbReadIndex {
230
238
  throw new Error("READ_STORAGE_UNAVAILABLE");
231
239
  }
232
240
  key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
241
+ async hydrate(current, key) {
242
+ if (current.normalized) {
243
+ const owner = JSON.stringify(key);
244
+ const [threads, messages, positions] = await Promise.all([
245
+ this.threadRows.where("owner").equals(owner).toArray(),
246
+ this.messageRows.where("owner").equals(owner).toArray(),
247
+ this.positionRows.where("owner").equals(owner).toArray(),
248
+ ]);
249
+ const byThread = new Map();
250
+ for (const row of messages) {
251
+ if (!byThread.has(row.thread))
252
+ byThread.set(row.thread, new Map());
253
+ byThread.get(row.thread).set(row.id, row.value);
254
+ }
255
+ const byPosition = new Map(positions.map(row => [row.id, row]));
256
+ const byId = new Map(threads.map(row => [row.id, row]));
257
+ const { normalized: _normalized, threadOrder, ...state } = current;
258
+ const result = { ...state, threads: (threadOrder ?? []).map(id => {
259
+ const row = byId.get(id);
260
+ const position = byPosition.get(id);
261
+ if (!row || !position)
262
+ throw new Error("READ_STORAGE_INCOMPLETE");
263
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
264
+ const message = byThread.get(id)?.get(messageId);
265
+ if (!message)
266
+ throw new Error("READ_STORAGE_INCOMPLETE");
267
+ return message;
268
+ }) };
269
+ }) };
270
+ this.normalizedStates.add(result);
271
+ return result;
272
+ }
273
+ return current;
274
+ }
233
275
  async loadCurrent(appId, profileId) {
234
276
  const current = await this.profiles.get(this.key(appId, profileId));
235
- if (current || !this.migrateLegacy || this.storageScope === undefined)
277
+ if (current?.normalized)
278
+ return this.hydrate(current, this.key(appId, profileId));
279
+ if (current) {
280
+ if (this.migrateLegacy)
281
+ await this.persist(appId, profileId, undefined, current);
236
282
  return current;
283
+ }
284
+ if (!this.migrateLegacy || this.storageScope === undefined)
285
+ return undefined;
237
286
  const markerKey = [appId, profileId, "legacy-owner-v1"];
238
287
  const marker = await this.profiles.get(markerKey);
239
288
  if (marker && marker.legacyOwner !== this.storageScope)
240
289
  return undefined;
241
- const legacy = await this.profiles.get([appId, profileId]);
242
- if (!legacy)
290
+ const legacyRow = await this.profiles.get([appId, profileId]);
291
+ if (!legacyRow)
243
292
  return undefined;
293
+ const legacy = await this.hydrate(legacyRow, [appId, profileId]);
244
294
  if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
245
295
  throw new Error("READ_IDENTITY_MISMATCH");
246
296
  const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
247
297
  await this.profiles.put(ownership, markerKey);
248
- await this.profiles.put(legacy, this.key(appId, profileId));
298
+ await this.persist(appId, profileId, undefined, legacy);
249
299
  await this.profiles.delete([appId, profileId]);
300
+ const oldOwner = JSON.stringify([appId, profileId]);
301
+ await this.threadRows.where("owner").equals(oldOwner).delete();
302
+ await this.messageRows.where("owner").equals(oldOwner).delete();
303
+ await this.positionRows.where("owner").equals(oldOwner).delete();
250
304
  return legacy;
251
305
  }
306
+ /** Message bodies are immutable for local read observations: keep them out of
307
+ * profile/cursor writes, and update only changed normalized rows on sync. */
308
+ async persist(appId, profileId, previous, next) {
309
+ if (previous && !this.normalizedStates.has(previous))
310
+ previous = undefined;
311
+ const owner = JSON.stringify(this.key(appId, profileId));
312
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
313
+ const nextIds = new Set(next.threads.map(thread => thread.id));
314
+ if (nextIds.size !== next.threads.length)
315
+ throw new Error("DUPLICATE_READ_THREAD");
316
+ const threadWrites = [], messageWrites = [], positionWrites = [];
317
+ const messageDeletes = [];
318
+ for (const thread of next.threads) {
319
+ const old = oldThreads.get(thread.id);
320
+ if (old === thread)
321
+ continue;
322
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
323
+ const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
324
+ if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
325
+ positionWrites.push(position);
326
+ const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
327
+ if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
328
+ const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
329
+ const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
330
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
331
+ threadWrites.push(metadata);
332
+ }
333
+ if (old?.messages === messages)
334
+ continue;
335
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
336
+ const ids = new Set();
337
+ for (const message of messages) {
338
+ if (ids.has(message.id))
339
+ throw new Error("INVALID_READ_MESSAGE");
340
+ ids.add(message.id);
341
+ const prior = byId.get(message.id);
342
+ if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
343
+ messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
344
+ }
345
+ for (const id of byId.keys())
346
+ if (!ids.has(id))
347
+ messageDeletes.push([owner, thread.id, id]);
348
+ }
349
+ for (const id of oldThreads.keys())
350
+ if (!nextIds.has(id)) {
351
+ await this.threadRows.delete([owner, id]);
352
+ await this.positionRows.delete([owner, id]);
353
+ await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
354
+ }
355
+ // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
356
+ // atomically; bounding total snapshot transaction bytes is separate work.
357
+ for (let offset = 0; offset < positionWrites.length; offset += 128)
358
+ await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
359
+ for (let offset = 0; offset < threadWrites.length; offset += 128)
360
+ await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
361
+ for (let offset = 0; offset < messageWrites.length; offset += 128)
362
+ await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
363
+ for (let offset = 0; offset < messageDeletes.length; offset += 128)
364
+ await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
365
+ const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
366
+ const nextHeader = header(next);
367
+ if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
368
+ await this.profiles.put(nextHeader, this.key(appId, profileId));
369
+ this.normalizedStates.add(next);
370
+ }
252
371
  async load(appId, profileId) {
253
372
  return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
254
373
  }
@@ -270,7 +389,7 @@ export class IndexedDbReadIndex {
270
389
  return state;
271
390
  const next = removeReadTeam(state, teamId, knownThreadIds);
272
391
  await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
273
- await this.profiles.put(next, this.key(appId, profileId));
392
+ await this.persist(appId, profileId, state, next);
274
393
  return next;
275
394
  });
276
395
  }
@@ -280,8 +399,7 @@ export class IndexedDbReadIndex {
280
399
  const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
281
400
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
282
401
  throw new Error("READ_IDENTITY_MISMATCH");
283
- if (JSON.stringify(next) !== JSON.stringify(previous))
284
- await this.profiles.put(next, this.key(appId, profileId));
402
+ await this.persist(appId, profileId, previous, next);
285
403
  return next;
286
404
  });
287
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.25",
3
+ "version": "0.12.0-next.26",
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",