@pmate/chat-sdk 0.12.0-next.71 → 0.12.0-next.73

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.
@@ -0,0 +1,4 @@
1
+ import type { Dexie } from "dexie";
2
+ /** WebKit can invalidate an IDB connection while the application is suspended.
3
+ * Only replay a whole rejected transaction; never replay individual requests or clear data. */
4
+ export declare function recoverableTransactions(db: Dexie, isClosed: () => boolean): Dexie["transaction"];
@@ -0,0 +1,27 @@
1
+ /** WebKit can invalidate an IDB connection while the application is suspended.
2
+ * Only replay a whole rejected transaction; never replay individual requests or clear data. */
3
+ export function recoverableTransactions(db, isClosed) {
4
+ const transaction = (...args) => db.transaction.apply(db, args);
5
+ let recovery;
6
+ return (async (...args) => {
7
+ try {
8
+ return await transaction(...args);
9
+ }
10
+ catch (error) {
11
+ const failure = error;
12
+ if (isClosed() || !(failure.name === "UnknownError" || failure.inner?.name === "UnknownError") || !/without an in-progress transaction/i.test(failure.message ?? ""))
13
+ throw error;
14
+ if (!recovery) {
15
+ db.close({ disableAutoOpen: false });
16
+ const opening = db.open();
17
+ recovery = opening;
18
+ void opening.finally(() => { if (recovery === opening)
19
+ recovery = undefined; }).catch(() => { });
20
+ }
21
+ await recovery;
22
+ if (isClosed())
23
+ throw error;
24
+ return transaction(...args);
25
+ }
26
+ });
27
+ }
@@ -35,12 +35,14 @@ export declare const messageScopeKey: (scope: MessageScope) => string;
35
35
  export declare class IndexedDbMessageStore<T extends StoredMessage> {
36
36
  private scope;
37
37
  private merge;
38
+ private transact;
38
39
  private db;
39
40
  private rows;
40
41
  private legacy;
41
42
  private coverage;
42
43
  private accessRows;
43
44
  private controls;
45
+ private closed;
44
46
  private key;
45
47
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
46
48
  indexedDB?: IDBFactory;
@@ -1,3 +1,4 @@
1
+ import { recoverableTransactions } from "./indexedDbRecovery.js";
1
2
  import { Dexie } from "dexie";
2
3
  import { quotedMessageIds, redactMessageQuotes } from "./messageQuotes.js";
3
4
  import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
@@ -7,18 +8,21 @@ export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.acc
7
8
  export class IndexedDbMessageStore {
8
9
  scope;
9
10
  merge;
11
+ transact;
10
12
  db;
11
13
  rows;
12
14
  legacy;
13
15
  coverage;
14
16
  accessRows;
15
17
  controls;
18
+ closed = false;
16
19
  key;
17
20
  constructor(scope, merge, options = {}) {
18
21
  this.scope = scope;
19
22
  this.merge = merge;
20
23
  this.key = messageScopeKey(scope);
21
24
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
25
+ this.transact = recoverableTransactions(this.db, () => this.closed);
22
26
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
23
27
  this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
24
28
  this.db.version(3).stores({ scopeAccess: "scope" });
@@ -34,7 +38,7 @@ export class IndexedDbMessageStore {
34
38
  async read(thread, limit = 100, before) {
35
39
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
36
40
  throw new Error("INVALID_MESSAGE_LIMIT");
37
- const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
41
+ const page = await this.transact("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
38
42
  const access = await this.scopeAccess();
39
43
  if (access.revoked)
40
44
  return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
@@ -48,7 +52,7 @@ export class IndexedDbMessageStore {
48
52
  async readSelected(thread, ids) {
49
53
  if (ids.length > 100 || ids.some(id => !id || id.length > 256))
50
54
  throw new Error("INVALID_MESSAGE_SELECTION");
51
- const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
55
+ const page = await this.transact("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
52
56
  const access = await this.scopeAccess();
53
57
  const coverage = this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
54
58
  if (access.revoked || coverage?.revoked || (coverage?.scopeVersion ?? 0) !== access.version)
@@ -70,7 +74,7 @@ export class IndexedDbMessageStore {
70
74
  if (bytes > 1_048_576)
71
75
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
72
76
  }
73
- return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
77
+ return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
74
78
  const access = await this.scopeAccess();
75
79
  const coverage = await this.coverage.get([this.key, thread]);
76
80
  if (access.revoked || coverage?.revoked)
@@ -109,7 +113,7 @@ export class IndexedDbMessageStore {
109
113
  async readFallback(thread, limit = 100, before) {
110
114
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
111
115
  throw new Error("INVALID_MESSAGE_LIMIT");
112
- const page = await this.db.transaction("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
116
+ const page = await this.transact("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
113
117
  const access = await this.scopeAccess();
114
118
  const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
115
119
  if (coverage?.revoked)
@@ -127,7 +131,7 @@ export class IndexedDbMessageStore {
127
131
  throw new Error("MESSAGE_SCOPE_MISMATCH");
128
132
  if (messages.length > 1000)
129
133
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
130
- return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
134
+ return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
131
135
  const access = await this.scopeAccess();
132
136
  if ((guard?.scopeVersion ?? 0) !== access.version)
133
137
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -207,7 +211,7 @@ export class IndexedDbMessageStore {
207
211
  // v3 caches migrate only source proofs from already-read pages, with no body
208
212
  // rewrite. Steady reads remain a single read-only IndexedDB transaction.
209
213
  if (pendingControls?.length)
210
- await this.db.transaction("rw", this.controls, this.coverage, this.accessRows, async () => {
214
+ await this.transact("rw", this.controls, this.coverage, this.accessRows, async () => {
211
215
  const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
212
216
  if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
213
217
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -218,21 +222,22 @@ export class IndexedDbMessageStore {
218
222
  return result;
219
223
  }
220
224
  async windows() {
221
- return this.db.transaction("r", this.coverage, this.accessRows, async () => {
225
+ return this.transact("r", this.coverage, this.accessRows, async () => {
222
226
  if ((await this.scopeAccess()).revoked)
223
227
  return [];
224
228
  return this.coverage.where("scope").equals(this.key).toArray();
225
229
  });
226
230
  }
227
231
  async scopeAccess() {
228
- const access = (await this.accessRows.get(this.key)) ?? { scope: this.key, version: 0, revoked: false };
232
+ const read = () => this.accessRows.get(this.key);
233
+ const access = (await (Dexie.currentTransaction?.db === this.db ? read() : this.transact("r", this.accessRows, read))) ?? { scope: this.key, version: 0, revoked: false };
229
234
  if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
230
235
  throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
231
236
  return access;
232
237
  }
233
238
  /** Only fresh, server-confirmed authorization may lift this durable tombstone. */
234
239
  async authorizeScope(expectedVersion) {
235
- await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
240
+ await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
236
241
  const access = await this.scopeAccess();
237
242
  if (access.version !== expectedVersion)
238
243
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -253,7 +258,7 @@ export class IndexedDbMessageStore {
253
258
  }
254
259
  /** Persist the denial first; failed cleanup must never make cached rows readable. */
255
260
  async revokeScope(expectedVersion) {
256
- const access = await this.db.transaction("rw", this.accessRows, async () => {
261
+ const access = await this.transact("rw", this.accessRows, async () => {
257
262
  const old = await this.scopeAccess();
258
263
  if (expectedVersion !== undefined && old.version !== expectedVersion)
259
264
  return old;
@@ -261,7 +266,7 @@ export class IndexedDbMessageStore {
261
266
  await this.accessRows.put(next);
262
267
  return next;
263
268
  });
264
- await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
269
+ await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
265
270
  const current = await this.scopeAccess();
266
271
  if (current.version === access.version && current.revoked)
267
272
  await this.clearScopeRows();
@@ -270,7 +275,7 @@ export class IndexedDbMessageStore {
270
275
  }
271
276
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
272
277
  async resetThread(thread, revoked = false, scopeVersion = 0) {
273
- return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
278
+ return this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
274
279
  const access = await this.scopeAccess();
275
280
  if (access.version !== scopeVersion)
276
281
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -287,7 +292,7 @@ export class IndexedDbMessageStore {
287
292
  });
288
293
  }
289
294
  async removeThread(thread) {
290
- await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
295
+ await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
291
296
  await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
292
297
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
293
298
  await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
@@ -295,14 +300,14 @@ export class IndexedDbMessageStore {
295
300
  });
296
301
  }
297
302
  async clear() {
298
- await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
303
+ await this.transact("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
299
304
  await this.controls.where("scope").equals(this.key).delete();
300
305
  await this.legacy.where("scope").equals(this.key).delete();
301
306
  await this.rows.where("scope").equals(this.key).delete();
302
307
  await this.coverage.where("scope").equals(this.key).delete();
303
308
  });
304
309
  }
305
- close() { this.db.close(); }
310
+ close() { this.closed = true; this.db.close(); }
306
311
  }
307
312
  /** Keep tombstones monotonic; applications can supply stricter domain reducers. */
308
313
  export function mergeStoredMessage(previous, incoming) {
@@ -238,7 +238,7 @@ export class ReadCountsClient {
238
238
  this.heartbeat = undefined;
239
239
  }
240
240
  this.options.onError?.(error);
241
- if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
241
+ if (!isResponseCacheAuthorityError(error) && (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) || /READ_COUNTS_HTTP_(409|429)/.test(String(error))) && this.retries < 3) {
242
242
  retryDelay = 1000 * 2 ** this.retries++;
243
243
  this.dirty = true;
244
244
  }
@@ -20,6 +20,7 @@ export type ResponseCacheOptions<T> = {
20
20
  export declare class IndexedDbResponseCache<T> {
21
21
  private scope;
22
22
  private options;
23
+ private transact;
23
24
  private db;
24
25
  private rows;
25
26
  private epochs;
@@ -1,3 +1,4 @@
1
+ import { recoverableTransactions } from "./indexedDbRecovery.js";
1
2
  import { Dexie } from "dexie";
2
3
  import { WindowedTasks } from "./coalescer.js";
3
4
  export function isResponseCacheAuthorityError(error) {
@@ -18,6 +19,7 @@ export async function assertResponseCacheAccess(access, expectedVersion) {
18
19
  export class IndexedDbResponseCache {
19
20
  scope;
20
21
  options;
22
+ transact;
21
23
  db;
22
24
  rows;
23
25
  epochs;
@@ -34,6 +36,7 @@ export class IndexedDbResponseCache {
34
36
  if (!Number.isSafeInteger(value) || value < 1 || value > max)
35
37
  throw new Error("INVALID_RESPONSE_CACHE_LIMIT");
36
38
  this.db = new Dexie(options.name ?? "pmate-chat-response-windows", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
39
+ this.transact = recoverableTransactions(this.db, () => this.closed);
37
40
  this.db.version(1).stores({ windows: "[scope+key],scope", epochs: "scope" });
38
41
  this.rows = this.db.table("windows");
39
42
  this.epochs = this.db.table("epochs");
@@ -45,14 +48,14 @@ export class IndexedDbResponseCache {
45
48
  if (!this.options.access)
46
49
  return;
47
50
  const access = await this.options.access();
48
- const stored = await this.epochs.get(this.scope);
51
+ const stored = await this.transact("r", this.epochs, () => this.epochs.get(this.scope));
49
52
  const storedVersion = stored?.accessVersion ?? 0;
50
53
  if (access.version < storedVersion)
51
54
  throw new Error("RESPONSE_CACHE_SCOPE_CHANGED");
52
55
  // Ordinary cache reads must not take an IndexedDB write lock. Only an
53
56
  // authority transition needs the atomic epoch bump and range deletion.
54
57
  if (access.version > storedVersion)
55
- await this.db.transaction("rw", this.rows, this.epochs, async () => {
58
+ await this.transact("rw", this.rows, this.epochs, async () => {
56
59
  const current = await this.epochs.get(this.scope);
57
60
  const version = current?.accessVersion ?? 0;
58
61
  if (access.version < version)
@@ -69,7 +72,7 @@ export class IndexedDbResponseCache {
69
72
  if (this.closed)
70
73
  throw new Error("RESPONSE_CACHE_CLOSED");
71
74
  await this.synchronizeAccess();
72
- return this.db.transaction("r", this.rows, this.epochs, async () => {
75
+ return this.transact("r", this.rows, this.epochs, async () => {
73
76
  const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
74
77
  const row = await this.rows.get([this.scope, key]);
75
78
  return { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined };
@@ -82,7 +85,7 @@ export class IndexedDbResponseCache {
82
85
  if (keys.length > 128)
83
86
  throw new Error("RESPONSE_CACHE_SELECTION_TOO_LARGE");
84
87
  await this.synchronizeAccess();
85
- return this.db.transaction("r", this.rows, this.epochs, async () => {
88
+ return this.transact("r", this.rows, this.epochs, async () => {
86
89
  const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0, unique = [...new Set(keys)];
87
90
  const rows = await this.rows.bulkGet(unique.map(key => [this.scope, key]));
88
91
  return new Map(unique.map((key, index) => { const row = rows[index]; return [key, { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined }]; }));
@@ -109,7 +112,7 @@ export class IndexedDbResponseCache {
109
112
  return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
110
113
  if (!Number.isSafeInteger(version) || version < 1)
111
114
  return Promise.reject(new Error("INVALID_RESPONSE_SCOPE_VERSION"));
112
- const task = this.db.transaction("rw", this.rows, this.epochs, async () => {
115
+ const task = this.transact("rw", this.rows, this.epochs, async () => {
113
116
  const current = await this.epochs.get(this.scope);
114
117
  if ((current?.accessVersion ?? 0) >= version)
115
118
  return;
@@ -123,7 +126,7 @@ export class IndexedDbResponseCache {
123
126
  if (this.closed)
124
127
  return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
125
128
  const task = (async () => {
126
- await this.db.transaction("rw", this.rows, this.epochs, async () => {
129
+ await this.transact("rw", this.rows, this.epochs, async () => {
127
130
  const current = await this.epochs.get(this.scope);
128
131
  const epoch = (current?.epoch ?? 0) + 1;
129
132
  await this.epochs.put({ scope: this.scope, epoch, accessVersion: current?.accessVersion });
@@ -150,7 +153,7 @@ export class IndexedDbResponseCache {
150
153
  const batch = this.pending.slice(0, this.options.batchRecords ?? 128);
151
154
  try {
152
155
  await this.synchronizeAccess();
153
- const outcome = await this.db.transaction("rw", this.rows, this.epochs, async () => {
156
+ const outcome = await this.transact("rw", this.rows, this.epochs, async () => {
154
157
  const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
155
158
  const valid = batch.filter(write => write.row.epoch === epoch);
156
159
  const conditional = valid.filter(write => write.expectedRevision !== undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.71",
3
+ "version": "0.12.0-next.73",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",