@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.
- package/lib/runtime/readIndex.d.ts +8 -0
- package/lib/runtime/readIndex.js +136 -8
- package/package.json +1 -1
|
@@ -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;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -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
|
|
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
|
|
242
|
-
if (!
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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