@pmate/chat-sdk 0.12.0-next.57 → 0.12.0-next.58

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
@@ -254,3 +254,24 @@ permission change in another team does not discard this team's observations.
254
254
  Ordinary shutdown waits for accepted local reading writes, while access withdrawal
255
255
  rejects late results. Retired asynchronous reading entries reject through promises;
256
256
  refresh/subscription teardown remains safe.
257
+
258
+
259
+ ### Reading storage recovery
260
+
261
+ Large reading snapshots use a persisted journal. The full plan becomes durable
262
+ before canonical rows change; bounded transactions apply it, and the sync cursor
263
+ commits last. A reader recovers an active plan before returning a snapshot. Failed
264
+ staging leaves the previous snapshot authoritative; failure after activation keeps
265
+ the old cursor until recovery completes. Pending reads retain their operation IDs.
266
+
267
+ Writes use at most 128 records per transaction, including journal/lease records,
268
+ and conservatively budget one MiB per batch. Message order is stored per row so a
269
+ source no longer needs a growing array of every message ID. Ordinary observations
270
+ continue to update only positions and pending operations. Locks serialize each
271
+ owner across documents; a fenced IndexedDB lease supports contexts without Web Locks.
272
+
273
+ The private reading database format is now 60. Existing stores are renamed by
274
+ IndexedDB metadata operations, preserving keys and records. Older SDKs cannot
275
+ write through the former namespace and need a page reload to use the new format.
276
+ The message-body cache is separate and unchanged. The public reading state remains
277
+ schema version 1; applications do not select storage formats or recovery steps.
@@ -0,0 +1,35 @@
1
+ import { Dexie } from "dexie";
2
+ export type ReadMutation = {
3
+ table: "readingThreads" | "readingMessages" | "readingPositions";
4
+ key: string[];
5
+ value?: unknown;
6
+ };
7
+ type Plan = {
8
+ profileKey: string[];
9
+ header: unknown;
10
+ versions?: unknown;
11
+ };
12
+ /** Cross-document serialization; the persisted journal is the crash boundary. */
13
+ export declare function lockReadOwner<T>(factory: IDBFactory, name: string, owner: string, run: () => Promise<T>, fallback?: (run: () => Promise<T>) => Promise<T>): Promise<T>;
14
+ /** Stage the full plan first, roll forward bounded batches, publish cursor last. */
15
+ export declare class ReadBatchJournal {
16
+ private db;
17
+ private closed;
18
+ private rows;
19
+ private knownOwners;
20
+ private leases;
21
+ private acquiring;
22
+ constructor(db: Dexie, closed: () => boolean);
23
+ lease<T>(owner: string, run: () => Promise<T>): Promise<T>;
24
+ releaseLeases(): Promise<void>;
25
+ checkLeases(renew?: boolean): Promise<void>;
26
+ private check;
27
+ private tables;
28
+ recover(owner: string): Promise<void>;
29
+ private cleanup;
30
+ private apply;
31
+ private validatePlan;
32
+ private finish;
33
+ commit(owner: string, operations: Iterable<ReadMutation>, plan: Plan): Promise<void>;
34
+ }
35
+ export {};
@@ -0,0 +1,210 @@
1
+ import { Dexie } from "dexie";
2
+ const LIMIT = 124, BYTES = 1024 * 1024, LEASE_MS = 5_000;
3
+ const size = (value) => JSON.stringify(value).length * 3;
4
+ const localLocks = new WeakMap();
5
+ /** Cross-document serialization; the persisted journal is the crash boundary. */
6
+ export async function lockReadOwner(factory, name, owner, run, fallback) {
7
+ const key = JSON.stringify(["pmate-reading", name, owner]);
8
+ if (typeof navigator !== "undefined" && navigator.locks)
9
+ return navigator.locks.request(key, run);
10
+ if (typeof window !== "undefined") {
11
+ if (!fallback)
12
+ throw new Error("READ_STORAGE_LOCK_UNAVAILABLE");
13
+ return fallback(run);
14
+ }
15
+ let locks = localLocks.get(factory);
16
+ if (!locks) {
17
+ locks = new Map();
18
+ localLocks.set(factory, locks);
19
+ }
20
+ const previous = locks.get(key) ?? Promise.resolve();
21
+ let release;
22
+ const current = new Promise(resolve => { release = resolve; });
23
+ locks.set(key, current);
24
+ await previous;
25
+ try {
26
+ return await run();
27
+ }
28
+ finally {
29
+ release();
30
+ if (locks.get(key) === current)
31
+ locks.delete(key);
32
+ }
33
+ }
34
+ /** Stage the full plan first, roll forward bounded batches, publish cursor last. */
35
+ export class ReadBatchJournal {
36
+ db;
37
+ closed;
38
+ rows;
39
+ knownOwners = new Set();
40
+ leases = new Map();
41
+ acquiring = new Set();
42
+ constructor(db, closed) {
43
+ this.db = db;
44
+ this.closed = closed;
45
+ this.rows = db.table("readBatches");
46
+ }
47
+ async lease(owner, run) {
48
+ const token = crypto.randomUUID();
49
+ for (;;) {
50
+ this.check();
51
+ const attempt = (async () => {
52
+ const acquired = await this.db.transaction("rw", this.rows, async () => {
53
+ const old = (await this.rows.get([owner, "lease"]))?.lease;
54
+ if (old && old.expires > Date.now())
55
+ return false;
56
+ await this.rows.put({ owner, key: "lease", lease: { token, expires: Date.now() + LEASE_MS } });
57
+ return true;
58
+ });
59
+ if (acquired)
60
+ this.leases.set(owner, token);
61
+ return acquired;
62
+ })();
63
+ this.acquiring.add(attempt);
64
+ const acquired = await attempt.finally(() => this.acquiring.delete(attempt));
65
+ if (acquired)
66
+ break;
67
+ await new Promise(resolve => setTimeout(resolve, 25));
68
+ }
69
+ try {
70
+ const result = await run();
71
+ this.check();
72
+ await this.db.transaction("r", this.rows, () => this.checkLeases());
73
+ return result;
74
+ }
75
+ finally {
76
+ this.leases.delete(owner);
77
+ if (!this.closed())
78
+ await this.db.transaction("rw", this.rows, async () => { if ((await this.rows.get([owner, "lease"]))?.lease?.token === token)
79
+ await this.rows.delete([owner, "lease"]); });
80
+ }
81
+ }
82
+ async releaseLeases() {
83
+ await Promise.allSettled([...this.acquiring]);
84
+ for (const [owner, token] of this.leases)
85
+ await this.db.transaction("rw", this.rows, async () => { if ((await this.rows.get([owner, "lease"]))?.lease?.token === token)
86
+ await this.rows.delete([owner, "lease"]); });
87
+ }
88
+ async checkLeases(renew = false) {
89
+ for (const [owner, token] of this.leases) {
90
+ if ((await this.rows.get([owner, "lease"]))?.lease?.token !== token)
91
+ throw new Error("READ_STORAGE_LOCK_LOST");
92
+ if (renew)
93
+ await this.rows.put({ owner, key: "lease", lease: { token, expires: Date.now() + LEASE_MS } });
94
+ }
95
+ }
96
+ check() { if (this.closed())
97
+ throw new Error("READ_STORAGE_CLOSED"); }
98
+ tables() { return [this.rows, ...["readingProfiles", "readingVersions", "readingThreads", "readingMessages", "readingPositions"].map(name => this.db.table(name))]; }
99
+ async recover(owner) {
100
+ this.check();
101
+ const active = (await this.rows.get([owner, "active"]))?.active;
102
+ if (!active) {
103
+ if (!this.knownOwners.has(owner)) {
104
+ await this.cleanup(owner);
105
+ this.knownOwners.add(owner);
106
+ }
107
+ return;
108
+ }
109
+ const plan = (await this.rows.get([owner, `${active.id}:plan`]))?.plan;
110
+ if (!plan || typeof active.id !== "string" || !Number.isSafeInteger(active.next) || !Number.isSafeInteger(active.total) || active.next < 0 || active.total < 1 || active.next > active.total)
111
+ throw new Error("READ_STORAGE_INCOMPLETE");
112
+ this.validatePlan(owner, plan);
113
+ while (active.next < active.total) {
114
+ this.check();
115
+ await this.db.transaction("rw", this.tables(), async () => {
116
+ await this.checkLeases();
117
+ const chunk = await this.rows.get([owner, `${active.id}:${active.next}`]);
118
+ if (!chunk?.operations || chunk.operations.length > LIMIT || chunk.operations.some(operation => !["readingThreads", "readingMessages", "readingPositions"].includes(operation.table) || operation.key[0] !== owner || operation.value !== undefined && operation.value.owner !== owner))
119
+ throw new Error("READ_STORAGE_INCOMPLETE");
120
+ await this.apply(chunk.operations);
121
+ await this.rows.put({ owner, key: "active", active: { ...active, next: active.next + 1 } });
122
+ await this.checkLeases(true);
123
+ });
124
+ active.next++;
125
+ await new Promise(resolve => setTimeout(resolve, 0));
126
+ }
127
+ this.check();
128
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.finish(plan); await this.rows.delete([owner, "active"]); await this.checkLeases(true); });
129
+ await this.cleanup(owner);
130
+ this.knownOwners.add(owner);
131
+ }
132
+ async cleanup(owner) {
133
+ for (;;) {
134
+ this.check();
135
+ const keys = await this.rows.where("owner").equals(owner).limit(LIMIT).primaryKeys();
136
+ const removable = keys.filter(key => key[1] !== "lease");
137
+ if (removable.length)
138
+ await this.db.transaction("rw", this.rows, async () => { await this.checkLeases(); await this.rows.bulkDelete(removable); await this.checkLeases(true); });
139
+ const count = keys.length;
140
+ if (count < LIMIT)
141
+ return;
142
+ await new Promise(resolve => setTimeout(resolve, 0));
143
+ }
144
+ }
145
+ async apply(operations) {
146
+ for (const name of ["readingThreads", "readingMessages", "readingPositions"]) {
147
+ const selected = operations.filter(operation => operation.table === name);
148
+ const writes = selected.filter(operation => operation.value !== undefined).map(operation => operation.value);
149
+ const deletes = selected.filter(operation => operation.value === undefined).map(operation => operation.key);
150
+ if (writes.length)
151
+ await this.db.table(name).bulkPut(writes);
152
+ if (deletes.length)
153
+ await this.db.table(name).bulkDelete(deletes);
154
+ }
155
+ }
156
+ validatePlan(owner, plan) {
157
+ const header = plan.header;
158
+ if (!Array.isArray(plan.profileKey) || JSON.stringify(plan.profileKey) !== owner || !header || header.schemaVersion !== 1 || header.appId !== plan.profileKey[0] || header.profileId !== plan.profileKey[1])
159
+ throw new Error("READ_IDENTITY_MISMATCH");
160
+ }
161
+ async finish(plan) {
162
+ if (plan.versions !== undefined)
163
+ await this.db.table("readingVersions").put(plan.versions, plan.profileKey);
164
+ await this.db.table("readingProfiles").put(plan.header, plan.profileKey);
165
+ }
166
+ async commit(owner, operations, plan) {
167
+ this.check();
168
+ this.validatePlan(owner, plan);
169
+ this.knownOwners.delete(owner);
170
+ await this.cleanup(owner);
171
+ if (size(plan) > BYTES)
172
+ throw new Error("READ_STORAGE_RECORD_TOO_LARGE");
173
+ const id = crypto.randomUUID(), iterator = operations[Symbol.iterator]();
174
+ let next = iterator.next(), total = 0;
175
+ while (!next.done) {
176
+ const batch = [];
177
+ let bytes = 512;
178
+ while (!next.done && batch.length < LIMIT) {
179
+ const weight = size(next.value);
180
+ if (weight > BYTES - 512)
181
+ throw new Error("READ_STORAGE_RECORD_TOO_LARGE");
182
+ if (batch.length && bytes + weight > BYTES)
183
+ break;
184
+ bytes += weight;
185
+ batch.push(next.value);
186
+ next = iterator.next();
187
+ }
188
+ if (total === 0 && next.done) {
189
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.apply(batch); await this.finish(plan); await this.checkLeases(true); });
190
+ this.knownOwners.add(owner);
191
+ return;
192
+ }
193
+ this.check();
194
+ await this.db.transaction("rw", this.rows, async () => { await this.checkLeases(); await this.rows.put({ owner, key: `${id}:${total++}`, operations: batch }); await this.checkLeases(true); });
195
+ await new Promise(resolve => setTimeout(resolve, 0));
196
+ }
197
+ if (!total) {
198
+ await this.db.transaction("rw", this.tables(), async () => { await this.checkLeases(); await this.finish(plan); await this.checkLeases(true); });
199
+ this.knownOwners.add(owner);
200
+ return;
201
+ }
202
+ await this.db.transaction("rw", this.rows, async () => {
203
+ await this.checkLeases();
204
+ await this.rows.put({ owner, key: `${id}:plan`, plan });
205
+ await this.rows.put({ owner, key: "active", active: { id, next: 0, total } });
206
+ await this.checkLeases(true);
207
+ });
208
+ await this.recover(owner);
209
+ }
210
+ }
@@ -110,7 +110,7 @@ export interface ReadIndexPersistence {
110
110
  /** Transform and persist atomically; UI publishes only the committed result. */
111
111
  update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
112
112
  }
113
- /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
113
+ /** Preserves the database and logical keys; store renaming fences old format writers. */
114
114
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
115
115
  private storageScope?;
116
116
  private migrateLegacy;
@@ -121,10 +121,17 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
121
121
  private messageRows;
122
122
  private positionRows;
123
123
  private normalizedStates;
124
+ private orderedStates;
125
+ private legacyClean;
124
126
  private observationMessages;
125
127
  private versions;
126
128
  private closed;
129
+ private journal;
130
+ private factory;
131
+ private schemaReady;
132
+ private ownerTail;
127
133
  constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
134
+ private owned;
128
135
  private transaction;
129
136
  private key;
130
137
  /** Keyset pages avoid materializing a second, unbounded array of durable rows.
@@ -133,9 +140,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
133
140
  private hydrate;
134
141
  private loadCurrent;
135
142
  private loadStored;
136
- /** Message bodies are immutable for local read observations: keep them out of
137
- * profile/cursor writes, and update only changed normalized rows on sync. */
138
- private persist;
143
+ private cleanupClaimedLegacy;
139
144
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
140
145
  loadVersioned(appId: string, profileId: string): Promise<{
141
146
  state: ReadIndexState | undefined;
@@ -145,6 +150,10 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
145
150
  invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
146
151
  private observe;
147
152
  update(appId: string, profileId: string, transform: ReadIndexTransform, observation?: ReadObservationScope): Promise<ReadIndexState>;
153
+ private updateOwned;
154
+ private writeState;
155
+ private cleanUnreachable;
156
+ private changes;
148
157
  close(): Promise<void>;
149
158
  }
150
159
  export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
@@ -1,3 +1,5 @@
1
+ import { prepareReadSchema } from "./readSchema.js";
2
+ import { ReadBatchJournal, lockReadOwner } from "./readBatchJournal.js";
1
3
  import { Dexie } from "dexie";
2
4
  import { isReadingControl } from "./readingContent.js";
3
5
  import { isValidChatTeamId } from "./thread.js";
@@ -206,7 +208,7 @@ export function removeReadTeam(state, teamId, knownThreadIds = []) {
206
208
  pending: state.pending.filter(operation => !removed.has(operation.threadId)),
207
209
  observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
208
210
  }
209
- /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
211
+ /** Preserves the database and logical keys; store renaming fences old format writers. */
210
212
  export class IndexedDbReadIndex {
211
213
  storageScope;
212
214
  migrateLegacy;
@@ -217,15 +219,22 @@ export class IndexedDbReadIndex {
217
219
  messageRows;
218
220
  positionRows;
219
221
  normalizedStates = new WeakSet();
222
+ orderedStates = new WeakSet();
223
+ legacyClean = new Set();
220
224
  observationMessages = new WeakMap();
221
225
  versions;
222
226
  closed = false;
227
+ journal;
228
+ factory;
229
+ schemaReady;
230
+ ownerTail = Promise.resolve();
223
231
  constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
224
232
  this.storageScope = storageScope;
225
233
  this.migrateLegacy = migrateLegacy;
226
234
  this.metadataOnly = metadataOnly;
227
235
  if (!factory)
228
236
  throw new Error("READ_STORAGE_UNAVAILABLE");
237
+ this.factory = factory;
229
238
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
230
239
  // The empty schema retains the legacy out-of-line primary key and records.
231
240
  this.database.version(1).stores({ profiles: "" });
@@ -237,12 +246,39 @@ export class IndexedDbReadIndex {
237
246
  // Keep v4 indexes for already-open older SDKs, and make source discovery
238
247
  // dense for legitimate legacy rows whose optional team field is absent.
239
248
  this.database.version(5).stores({ readThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId],[owner+id+value.kind],[owner+value.conversationId+value.kind+id]" });
240
- this.positionRows = this.database.table("readPositions");
241
- this.threadRows = this.database.table("readThreads");
242
- this.messageRows = this.database.table("readMessages");
243
- this.profiles = this.database.table("profiles");
244
- this.versions = this.database.table("versions");
249
+ this.database.version(6).stores({ profiles: "unsupportedReadingFormat", versions: "unsupportedReadingFormat", readingProfiles: "", readingVersions: "", readThreads: null, readMessages: null, readPositions: null, readingThreads: "[owner+id],owner,[owner+id+value.kind+value.teamId],[owner+value.conversationId+value.kind+value.teamId+id],[owner+id+value.conversationId],[owner+id+value.kind],[owner+value.conversationId+value.kind+id]", readingMessages: "[owner+thread+id],[owner+thread],owner", readingPositions: "[owner+id],owner", readBatches: "[owner+key],owner" });
250
+ this.journal = new ReadBatchJournal(this.database, () => this.closed);
251
+ this.positionRows = this.database.table("readingPositions");
252
+ this.threadRows = this.database.table("readingThreads");
253
+ this.messageRows = this.database.table("readingMessages");
254
+ this.profiles = this.database.table("readingProfiles");
255
+ this.versions = this.database.table("readingVersions");
245
256
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
257
+ this.schemaReady = prepareReadSchema(factory, name);
258
+ void this.schemaReady.catch(() => { });
259
+ }
260
+ async owned(appId, profileId, run) {
261
+ const previous = this.ownerTail;
262
+ let release;
263
+ this.ownerTail = new Promise(resolve => { release = resolve; });
264
+ await previous;
265
+ try {
266
+ if (this.closed)
267
+ throw new Error("READ_STORAGE_CLOSED");
268
+ await this.schemaReady;
269
+ if (this.closed)
270
+ throw new Error("READ_STORAGE_CLOSED");
271
+ const owner = JSON.stringify(this.key(appId, profileId));
272
+ return await lockReadOwner(this.factory, this.database.name, owner, async () => { await this.journal.recover(owner); return run(); }, work => this.journal.lease(owner, work));
273
+ }
274
+ catch (error) {
275
+ if (this.closed)
276
+ throw new Error("READ_STORAGE_CLOSED");
277
+ throw error;
278
+ }
279
+ finally {
280
+ release();
281
+ }
246
282
  }
247
283
  async transaction(mode, execute) {
248
284
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -250,11 +286,15 @@ export class IndexedDbReadIndex {
250
286
  throw new Error("READ_STORAGE_CLOSED");
251
287
  let started = false;
252
288
  try {
253
- return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
289
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows, this.database.table("readBatches")], async () => {
254
290
  if (this.closed)
255
291
  throw new Error("READ_STORAGE_CLOSED");
256
292
  started = true;
257
- return execute();
293
+ await this.journal.checkLeases();
294
+ const result = await execute();
295
+ if (mode === "rw")
296
+ await this.journal.checkLeases(true);
297
+ return result;
258
298
  });
259
299
  }
260
300
  catch (error) {
@@ -292,7 +332,7 @@ export class IndexedDbReadIndex {
292
332
  this.readPages(this.messageRows, owner, row => [row.owner, row.thread, row.id], row => {
293
333
  if (!byThread.has(row.thread))
294
334
  byThread.set(row.thread, new Map());
295
- byThread.get(row.thread).set(row.id, row.value);
335
+ byThread.get(row.thread).set(row.id, row);
296
336
  }),
297
337
  this.readPages(this.positionRows, owner, row => [row.owner, row.id], row => byPosition.set(row.id, row)),
298
338
  ]);
@@ -302,14 +342,27 @@ export class IndexedDbReadIndex {
302
342
  const position = byPosition.get(id);
303
343
  if (!row || !position)
304
344
  throw new Error("READ_STORAGE_INCOMPLETE");
305
- return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
306
- const message = byThread.get(id)?.get(messageId);
345
+ const stored = byThread.get(id) ?? new Map();
346
+ let messages;
347
+ if (row.messageCount !== undefined && !row.messageIds.length) {
348
+ const ordered = [...stored.values()], ranked = ordered.every(row => Number.isSafeInteger(row.ordinal));
349
+ ordered.sort((a, b) => ranked ? a.ordinal - b.ordinal : a.value.seq - b.value.seq);
350
+ if (ordered.length !== row.messageCount)
351
+ throw new Error("READ_STORAGE_INCOMPLETE");
352
+ messages = ordered.map(row => row.value);
353
+ }
354
+ else
355
+ messages = row.messageIds.map(messageId => {
356
+ const message = stored.get(messageId);
307
357
  if (!message)
308
358
  throw new Error("READ_STORAGE_INCOMPLETE");
309
- return message;
310
- }) };
359
+ return message.value;
360
+ });
361
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages };
311
362
  }) };
312
363
  this.normalizedStates.add(result);
364
+ if ((threadOrder ?? []).every(id => byId.get(id)?.messageCount !== undefined && !byId.get(id).messageIds.length && [...(byThread.get(id)?.values() ?? [])].every(row => Number.isSafeInteger(row.ordinal))))
365
+ this.orderedStates.add(result);
313
366
  return result;
314
367
  }
315
368
  return current;
@@ -320,140 +373,88 @@ export class IndexedDbReadIndex {
320
373
  return current;
321
374
  const metadata = readingMetadata(current);
322
375
  if (metadata !== current)
323
- await this.persist(appId, profileId, current, metadata);
376
+ await this.writeState(appId, profileId, current, metadata);
324
377
  return metadata;
325
378
  }
326
379
  async loadStored(appId, profileId) {
327
- const current = await this.profiles.get(this.key(appId, profileId));
328
- if (current?.normalized)
329
- return this.hydrate(current, this.key(appId, profileId));
380
+ const current = await this.transaction("r", async () => {
381
+ if (this.storageScope === undefined && (await this.profiles.get([appId, profileId, "legacy-owner-v1"]))?.legacyOwner)
382
+ return undefined;
383
+ const row = await this.profiles.get(this.key(appId, profileId));
384
+ if (row && (row.appId !== appId || row.profileId !== profileId || row.schemaVersion !== 1))
385
+ throw new Error("READ_IDENTITY_MISMATCH");
386
+ return row?.normalized ? this.hydrate(row, this.key(appId, profileId)) : row;
387
+ });
330
388
  if (current) {
331
- const next = this.metadataOnly ? readingMetadata(current) : current;
332
- if (this.migrateLegacy || this.metadataOnly)
333
- await this.persist(appId, profileId, undefined, next, true);
334
- return next;
389
+ await this.cleanupClaimedLegacy(appId, profileId);
390
+ if (!this.normalizedStates.has(current) && (this.migrateLegacy || this.metadataOnly)) {
391
+ const next = this.metadataOnly ? readingMetadata(current) : current;
392
+ await this.writeState(appId, profileId, undefined, next);
393
+ return next;
394
+ }
395
+ return current;
335
396
  }
336
397
  if (!this.migrateLegacy || this.storageScope === undefined)
337
398
  return undefined;
338
- const markerKey = [appId, profileId, "legacy-owner-v1"];
339
- const marker = await this.profiles.get(markerKey);
340
- if (marker && marker.legacyOwner !== this.storageScope)
341
- return undefined;
342
- const legacyRow = await this.profiles.get([appId, profileId]);
343
- if (!legacyRow)
399
+ const legacyOwner = JSON.stringify([appId, profileId]);
400
+ const legacy = await lockReadOwner(this.factory, this.database.name, legacyOwner, async () => {
401
+ await this.journal.recover(legacyOwner);
402
+ const allowed = await this.transaction("rw", async () => {
403
+ const key = [appId, profileId, "legacy-owner-v1"], marker = await this.profiles.get(key);
404
+ if (marker && marker.legacyOwner !== this.storageScope)
405
+ return false;
406
+ const raw = await this.profiles.get([appId, profileId]);
407
+ if (!raw)
408
+ return false;
409
+ if (raw.appId !== appId || raw.profileId !== profileId || raw.schemaVersion !== 1)
410
+ throw new Error("READ_IDENTITY_MISMATCH");
411
+ if (!marker)
412
+ await this.profiles.put({ ...createReadIndex(appId, profileId), legacyOwner: this.storageScope }, key);
413
+ return true;
414
+ });
415
+ if (!allowed)
416
+ return undefined;
417
+ return this.transaction("r", async () => { const row = await this.profiles.get([appId, profileId]); return row?.normalized ? this.hydrate(row, [appId, profileId]) : row; });
418
+ }, work => this.journal.lease(legacyOwner, work));
419
+ if (!legacy)
344
420
  return undefined;
345
- const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
346
- const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
347
421
  if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
348
422
  throw new Error("READ_IDENTITY_MISMATCH");
349
- const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
350
- await this.profiles.put(ownership, markerKey);
351
- await this.persist(appId, profileId, undefined, legacy);
352
- await this.profiles.delete([appId, profileId]);
353
- const oldOwner = JSON.stringify([appId, profileId]);
354
- await this.threadRows.where("owner").equals(oldOwner).delete();
355
- await this.messageRows.where("owner").equals(oldOwner).delete();
356
- await this.positionRows.where("owner").equals(oldOwner).delete();
357
- return legacy;
423
+ const next = this.metadataOnly ? readingMetadata(legacy) : legacy;
424
+ await this.writeState(appId, profileId, undefined, next);
425
+ await this.cleanupClaimedLegacy(appId, profileId);
426
+ return next;
358
427
  }
359
- /** Message bodies are immutable for local read observations: keep them out of
360
- * profile/cursor writes, and update only changed normalized rows on sync. */
361
- async persist(appId, profileId, previous, next, replaceLegacy = false) {
362
- if (previous && !this.normalizedStates.has(previous)) {
363
- previous = undefined;
364
- replaceLegacy = true;
365
- }
366
- const owner = JSON.stringify(this.key(appId, profileId));
367
- const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
368
- const nextIds = new Set(next.threads.map(thread => thread.id));
369
- if (nextIds.size !== next.threads.length)
370
- throw new Error("DUPLICATE_READ_THREAD");
371
- const threadWrites = [], messageWrites = [], positionWrites = [];
372
- const messageDeletes = [];
373
- for (const thread of next.threads) {
374
- const old = oldThreads.get(thread.id);
375
- if (old === thread)
376
- continue;
377
- const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
378
- const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
379
- if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
380
- positionWrites.push(position);
381
- const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
382
- if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
383
- const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
384
- const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
385
- if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
386
- threadWrites.push(metadata);
387
- }
388
- if (old?.messages === messages)
389
- continue;
390
- const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
391
- const ids = new Set();
392
- for (const message of messages) {
393
- if (ids.has(message.id))
394
- throw new Error("INVALID_READ_MESSAGE");
395
- ids.add(message.id);
396
- const prior = byId.get(message.id);
397
- if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
398
- messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
399
- }
400
- for (const id of byId.keys())
401
- if (!ids.has(id))
402
- messageDeletes.push([owner, thread.id, id]);
428
+ async cleanupClaimedLegacy(appId, profileId) {
429
+ if (this.storageScope === undefined || !this.migrateLegacy)
430
+ return;
431
+ const key = JSON.stringify(this.key(appId, profileId));
432
+ if (this.legacyClean.has(key))
433
+ return;
434
+ const marker = await this.transaction("r", () => this.profiles.get([appId, profileId, "legacy-owner-v1"]));
435
+ if (marker?.legacyOwner === this.storageScope) {
436
+ await this.transaction("rw", () => this.profiles.delete([appId, profileId]));
437
+ await this.cleanUnreachable(JSON.stringify([appId, profileId]), undefined);
403
438
  }
404
- for (const id of oldThreads.keys())
405
- if (!nextIds.has(id)) {
406
- await this.threadRows.delete([owner, id]);
407
- await this.positionRows.delete([owner, id]);
408
- await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
409
- }
410
- // A legacy document can replace the full profile after normalization. Its
411
- // replacement is authoritative for the row set; discard unreachable bodies
412
- // in the same transaction before installing the rebuilt normalized records.
413
- if (replaceLegacy) {
414
- await this.messageRows.where("owner").equals(owner).delete();
415
- await this.threadRows.where("owner").equals(owner).delete();
416
- await this.positionRows.where("owner").equals(owner).delete();
417
- }
418
- // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
419
- // atomically; bounding total snapshot transaction bytes is separate work.
420
- for (let offset = 0; offset < positionWrites.length; offset += 128)
421
- await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
422
- for (let offset = 0; offset < threadWrites.length; offset += 128)
423
- await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
424
- for (let offset = 0; offset < messageWrites.length; offset += 128)
425
- await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
426
- for (let offset = 0; offset < messageDeletes.length; offset += 128)
427
- await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
428
- const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
429
- const nextHeader = header(next);
430
- if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
431
- await this.profiles.put(nextHeader, this.key(appId, profileId));
432
- this.normalizedStates.add(next);
439
+ this.legacyClean.add(key);
433
440
  }
434
441
  async load(appId, profileId) {
435
- return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", () => this.loadCurrent(appId, profileId));
442
+ return this.owned(appId, profileId, () => this.loadCurrent(appId, profileId));
436
443
  }
437
444
  async loadVersioned(appId, profileId) {
438
- return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", async () => ({
439
- state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
440
- }));
445
+ return this.owned(appId, profileId, async () => ({ state: await this.loadCurrent(appId, profileId), versions: await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}) }));
441
446
  }
442
447
  async loadVersions(appId, profileId) {
443
- return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
448
+ return this.owned(appId, profileId, () => this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}));
444
449
  }
445
450
  async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
446
451
  if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
447
452
  throw new Error("INVALID_READ_SCOPE_VERSION");
448
- return this.transaction("rw", async () => {
449
- const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
450
- const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
453
+ return this.owned(appId, profileId, async () => {
454
+ const teams = await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
451
455
  if (readTeamVersion(teams, teamId) >= version)
452
- return state;
453
- const next = removeReadTeam(state, teamId, knownThreadIds);
454
- await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
455
- await this.persist(appId, profileId, state, next);
456
- return next;
456
+ return await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
457
+ return this.updateOwned(appId, profileId, state => removeReadTeam(state, teamId, knownThreadIds), undefined, { teams: { ...teams, [teamId]: version } });
457
458
  });
458
459
  }
459
460
  async observe(appId, profileId, transform, scope) {
@@ -545,6 +546,8 @@ export class IndexedDbReadIndex {
545
546
  if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
546
547
  writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
547
548
  }
549
+ if (writes.length > 126)
550
+ return undefined;
548
551
  if (writes.length)
549
552
  await this.positionRows.bulkPut(writes);
550
553
  if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
@@ -564,22 +567,97 @@ export class IndexedDbReadIndex {
564
567
  return { ...base, threads, pending: result.pending, observedReadIntents: result.observedReadIntents };
565
568
  }
566
569
  async update(appId, profileId, transform, observation) {
567
- return this.transaction("rw", async () => {
568
- if (observation) {
569
- const next = await this.observe(appId, profileId, transform, observation);
570
- if (next)
571
- return next;
572
- }
573
- const previous = await this.loadCurrent(appId, profileId);
574
- const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
575
- const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
576
- if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
577
- throw new Error("READ_IDENTITY_MISMATCH");
578
- await this.persist(appId, profileId, previous, next);
570
+ return this.owned(appId, profileId, () => this.updateOwned(appId, profileId, transform, observation));
571
+ }
572
+ async updateOwned(appId, profileId, transform, observation, versions) {
573
+ if (observation) {
574
+ const observed = await this.transaction("rw", () => this.observe(appId, profileId, transform, observation));
575
+ if (observed)
576
+ return observed;
577
+ }
578
+ if (this.storageScope === undefined && (await this.profiles.get([appId, profileId, "legacy-owner-v1"]))?.legacyOwner)
579
+ throw new Error("READ_LEGACY_OWNERSHIP_CHANGED");
580
+ const loaded = { previous: await this.loadCurrent(appId, profileId), versions: await this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {}) };
581
+ const previous = loaded.previous, transformed = transform(previous ?? createReadIndex(appId, profileId), loaded.versions);
582
+ const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
583
+ if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
584
+ throw new Error("READ_IDENTITY_MISMATCH");
585
+ if (next === previous && !versions)
579
586
  return next;
580
- });
587
+ await this.writeState(appId, profileId, previous, next, versions);
588
+ return next;
589
+ }
590
+ async writeState(appId, profileId, previous, next, versions) {
591
+ const owner = JSON.stringify(this.key(appId, profileId)), header = { ...next, threads: [], normalized: true, threadOrder: next.threads.map(thread => thread.id) };
592
+ if (!previous || !this.orderedStates.has(previous))
593
+ await this.cleanUnreachable(owner, previous);
594
+ await this.journal.commit(owner, this.changes(owner, previous, next), { profileKey: this.key(appId, profileId), header, versions });
595
+ this.normalizedStates.add(next);
596
+ this.orderedStates.add(next);
597
+ }
598
+ async cleanUnreachable(owner, previous) {
599
+ const threads = new Map(previous?.threads.map(thread => [thread.id, new Set(thread.messages.map(message => message.id))]) ?? []);
600
+ for (const name of ["readingThreads", "readingPositions", "readingMessages"]) {
601
+ const table = this.database.table(name);
602
+ let after;
603
+ for (;;) {
604
+ const keys = await table.where(":id").between(after ?? [owner, Dexie.minKey], [owner, Dexie.maxKey], !after, true).limit(126).primaryKeys();
605
+ if (!keys.length)
606
+ break;
607
+ const dead = keys.filter(key => !threads.has(key[1]) || name === "readingMessages" && !threads.get(key[1]).has(key[2]));
608
+ if (dead.length)
609
+ await this.transaction("rw", () => table.bulkDelete(dead));
610
+ after = keys[keys.length - 1];
611
+ if (keys.length < 126)
612
+ break;
613
+ }
614
+ }
615
+ }
616
+ *changes(owner, previous, next) {
617
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []), ids = new Set(), modern = previous !== undefined && this.orderedStates.has(previous);
618
+ for (const thread of next.threads) {
619
+ if (ids.has(thread.id))
620
+ throw new Error("DUPLICATE_READ_THREAD");
621
+ ids.add(thread.id);
622
+ const old = oldThreads.get(thread.id);
623
+ if (old === thread && modern)
624
+ continue;
625
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
626
+ if (!modern || !old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
627
+ yield { table: "readingPositions", key: [owner, thread.id], value: { owner, id: thread.id, readThroughSeq, explicitReadMessageIds } };
628
+ const metadata = { owner, id: thread.id, value, messageIds: [], messageCount: messages.length };
629
+ const oldMetadata = old ? { owner, id: old.id, value: (({ messages: _m, readThroughSeq: _r, explicitReadMessageIds: _e, ...rest }) => rest)(old), messageIds: modern ? [] : old.messages.map(message => message.id), ...(modern ? { messageCount: old.messages.length } : {}) } : undefined;
630
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
631
+ yield { table: "readingThreads", key: [owner, thread.id], value: metadata };
632
+ if (old?.messages === messages && modern)
633
+ continue;
634
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []), ranks = new Map(old?.messages.map((message, index) => [message.id, index]) ?? []), seen = new Set();
635
+ for (const [ordinal, message] of messages.entries()) {
636
+ if (seen.has(message.id))
637
+ throw new Error("INVALID_READ_MESSAGE");
638
+ seen.add(message.id);
639
+ const prior = byId.get(message.id);
640
+ if (!modern || ranks.get(message.id) !== ordinal || prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
641
+ yield { table: "readingMessages", key: [owner, thread.id, message.id], value: { owner, thread: thread.id, id: message.id, value: message, ordinal } };
642
+ }
643
+ for (const id of byId.keys())
644
+ if (!seen.has(id))
645
+ yield { table: "readingMessages", key: [owner, thread.id, id] };
646
+ }
647
+ for (const old of oldThreads.values())
648
+ if (!ids.has(old.id)) {
649
+ yield { table: "readingThreads", key: [owner, old.id] };
650
+ yield { table: "readingPositions", key: [owner, old.id] };
651
+ for (const message of old.messages)
652
+ yield { table: "readingMessages", key: [owner, old.id, message.id] };
653
+ }
654
+ }
655
+ async close() { this.closed = true; try {
656
+ await this.journal.releaseLeases();
581
657
  }
582
- async close() { this.closed = true; this.database.close(); }
658
+ finally {
659
+ this.database.close();
660
+ } }
583
661
  }
584
662
  export function sourcesInConversation(state, conversationId) {
585
663
  return state.threads.filter(thread => (thread.conversationId ?? thread.id) === conversationId);
@@ -0,0 +1,2 @@
1
+ /** Metadata-only store renaming fences SDKs that cannot read chunked snapshots. */
2
+ export declare function prepareReadSchema(factory: IDBFactory, name: string): Promise<void>;
@@ -0,0 +1,58 @@
1
+ /** Metadata-only store renaming fences SDKs that cannot read chunked snapshots. */
2
+ export async function prepareReadSchema(factory, name) {
3
+ const existing = await new Promise((resolve, reject) => {
4
+ const request = factory.open(name);
5
+ let fresh = false;
6
+ request.onupgradeneeded = event => { if (event.oldVersion === 0) {
7
+ fresh = true;
8
+ request.transaction.abort();
9
+ } };
10
+ request.onerror = event => { event.preventDefault(); if (fresh)
11
+ resolve(undefined);
12
+ else
13
+ reject(request.error); };
14
+ request.onsuccess = () => resolve(request.result);
15
+ });
16
+ if (!existing)
17
+ return;
18
+ const prepared = existing.objectStoreNames.contains("readingProfiles") && existing.objectStoreNames.contains("readingMessages");
19
+ const version = existing.version;
20
+ existing.close();
21
+ if (version >= 60 && prepared)
22
+ return;
23
+ await new Promise((resolve, reject) => {
24
+ const request = factory.open(name, Math.max(60, version + 1));
25
+ request.onerror = () => reject(request.error);
26
+ request.onupgradeneeded = () => {
27
+ const db = request.result, tx = request.transaction;
28
+ for (const [before, after] of [["profiles", "readingProfiles"], ["versions", "readingVersions"], ["readThreads", "readingThreads"], ["readMessages", "readingMessages"], ["readPositions", "readingPositions"]]) {
29
+ if (db.objectStoreNames.contains(before)) {
30
+ if (db.objectStoreNames.contains(after)) {
31
+ tx.abort();
32
+ return;
33
+ }
34
+ tx.objectStore(before).name = after;
35
+ }
36
+ }
37
+ if (!db.objectStoreNames.contains("readingProfiles"))
38
+ db.createObjectStore("readingProfiles");
39
+ if (!db.objectStoreNames.contains("readingVersions"))
40
+ db.createObjectStore("readingVersions");
41
+ // Explicit-key writes from older SDKs must fail rather than recreate a cache
42
+ // beside a partially applied transition or silently return an empty history.
43
+ db.createObjectStore("profiles", { keyPath: "unsupportedReadingFormat" });
44
+ db.createObjectStore("versions", { keyPath: "unsupportedReadingFormat" });
45
+ const define = (name, keyPath, indexes) => {
46
+ const store = db.objectStoreNames.contains(name) ? tx.objectStore(name) : db.createObjectStore(name, { keyPath });
47
+ for (const [index, path] of Object.entries(indexes))
48
+ if (!store.indexNames.contains(index))
49
+ store.createIndex(index, path);
50
+ };
51
+ define("readingThreads", ["owner", "id"], { owner: "owner", "[owner+id+value.kind+value.teamId]": ["owner", "id", "value.kind", "value.teamId"], "[owner+value.conversationId+value.kind+value.teamId+id]": ["owner", "value.conversationId", "value.kind", "value.teamId", "id"], "[owner+id+value.conversationId]": ["owner", "id", "value.conversationId"], "[owner+id+value.kind]": ["owner", "id", "value.kind"], "[owner+value.conversationId+value.kind+id]": ["owner", "value.conversationId", "value.kind", "id"] });
52
+ define("readingMessages", ["owner", "thread", "id"], { owner: "owner", "[owner+thread]": ["owner", "thread"] });
53
+ define("readingPositions", ["owner", "id"], { owner: "owner" });
54
+ define("readBatches", ["owner", "key"], { owner: "owner" });
55
+ };
56
+ request.onsuccess = () => { request.result.close(); resolve(); };
57
+ });
58
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.57",
3
+ "version": "0.12.0-next.58",
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",