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

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,146 @@ 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, true);
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, replaceLegacy = false) {
309
+ if (previous && !this.normalizedStates.has(previous)) {
310
+ previous = undefined;
311
+ replaceLegacy = true;
312
+ }
313
+ const owner = JSON.stringify(this.key(appId, profileId));
314
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
315
+ const nextIds = new Set(next.threads.map(thread => thread.id));
316
+ if (nextIds.size !== next.threads.length)
317
+ throw new Error("DUPLICATE_READ_THREAD");
318
+ const threadWrites = [], messageWrites = [], positionWrites = [];
319
+ const messageDeletes = [];
320
+ for (const thread of next.threads) {
321
+ const old = oldThreads.get(thread.id);
322
+ if (old === thread)
323
+ continue;
324
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
325
+ const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
326
+ if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
327
+ positionWrites.push(position);
328
+ const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
329
+ if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
330
+ const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
331
+ const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
332
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
333
+ threadWrites.push(metadata);
334
+ }
335
+ if (old?.messages === messages)
336
+ continue;
337
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
338
+ const ids = new Set();
339
+ for (const message of messages) {
340
+ if (ids.has(message.id))
341
+ throw new Error("INVALID_READ_MESSAGE");
342
+ ids.add(message.id);
343
+ const prior = byId.get(message.id);
344
+ if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
345
+ messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
346
+ }
347
+ for (const id of byId.keys())
348
+ if (!ids.has(id))
349
+ messageDeletes.push([owner, thread.id, id]);
350
+ }
351
+ for (const id of oldThreads.keys())
352
+ if (!nextIds.has(id)) {
353
+ await this.threadRows.delete([owner, id]);
354
+ await this.positionRows.delete([owner, id]);
355
+ await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
356
+ }
357
+ // A legacy document can replace the full profile after normalization. Its
358
+ // replacement is authoritative for the row set; discard unreachable bodies
359
+ // in the same transaction before installing the rebuilt normalized records.
360
+ if (replaceLegacy) {
361
+ await this.messageRows.where("owner").equals(owner).delete();
362
+ await this.threadRows.where("owner").equals(owner).delete();
363
+ await this.positionRows.where("owner").equals(owner).delete();
364
+ }
365
+ // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
366
+ // atomically; bounding total snapshot transaction bytes is separate work.
367
+ for (let offset = 0; offset < positionWrites.length; offset += 128)
368
+ await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
369
+ for (let offset = 0; offset < threadWrites.length; offset += 128)
370
+ await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
371
+ for (let offset = 0; offset < messageWrites.length; offset += 128)
372
+ await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
373
+ for (let offset = 0; offset < messageDeletes.length; offset += 128)
374
+ await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
375
+ const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
376
+ const nextHeader = header(next);
377
+ if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
378
+ await this.profiles.put(nextHeader, this.key(appId, profileId));
379
+ this.normalizedStates.add(next);
380
+ }
252
381
  async load(appId, profileId) {
253
382
  return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
254
383
  }
@@ -270,7 +399,7 @@ export class IndexedDbReadIndex {
270
399
  return state;
271
400
  const next = removeReadTeam(state, teamId, knownThreadIds);
272
401
  await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
273
- await this.profiles.put(next, this.key(appId, profileId));
402
+ await this.persist(appId, profileId, state, next);
274
403
  return next;
275
404
  });
276
405
  }
@@ -280,8 +409,7 @@ export class IndexedDbReadIndex {
280
409
  const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
281
410
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
282
411
  throw new Error("READ_IDENTITY_MISMATCH");
283
- if (JSON.stringify(next) !== JSON.stringify(previous))
284
- await this.profiles.put(next, this.key(appId, profileId));
412
+ await this.persist(appId, profileId, previous, next);
285
413
  return next;
286
414
  });
287
415
  }
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.27",
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",