@pmate/chat-sdk 0.12.0-next.1 → 0.12.0-next.11

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.
@@ -1,3 +1,5 @@
1
+ import { IndexedDbResponseCache } from "./responseCache.js";
2
+ import { messageScopeKey } from "./messageStore.js";
1
3
  export class MessageReadersError extends Error {
2
4
  status;
3
5
  forbidden;
@@ -12,7 +14,17 @@ export class MessageReadersClient {
12
14
  options;
13
15
  pending = new Map();
14
16
  lifetime = new AbortController();
15
- constructor(options) { this.options = { ...options }; }
17
+ cache;
18
+ revoked = false;
19
+ generation = 0;
20
+ constructor(options) {
21
+ this.options = { ...options };
22
+ if (options.scope) {
23
+ if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
24
+ throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
25
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
26
+ }
27
+ }
16
28
  getReaders(query, signal) {
17
29
  if (this.lifetime.signal.aborted)
18
30
  return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
@@ -24,7 +36,7 @@ export class MessageReadersClient {
24
36
  const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
25
37
  let task = this.pending.get(key);
26
38
  if (!task) {
27
- task = this.request(input).finally(() => { if (this.pending.get(key) === task)
39
+ task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
28
40
  this.pending.delete(key); });
29
41
  this.pending.set(key, task);
30
42
  }
@@ -37,23 +49,76 @@ export class MessageReadersClient {
37
49
  task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
38
50
  });
39
51
  }
52
+ async cachedRequest(input, key) {
53
+ // Keep only the standard SDK window. Arbitrary page sizes must not multiply
54
+ // durable cache entries. Revision is part of the key, so changed counts do
55
+ // not reuse a page from a previous membership/read projection.
56
+ const cache = input.limit === 50 ? this.cache : undefined;
57
+ if (!cache)
58
+ return this.request(input);
59
+ const stored = await cache.read(key).catch(error => { this.options.onError?.(error); return undefined; });
60
+ if (this.lifetime.signal.aborted)
61
+ throw new Error("MESSAGE_READERS_CLOSED");
62
+ const generation = this.generation;
63
+ const cached = this.revoked ? undefined : stored?.cached;
64
+ if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
65
+ return structuredClone(cached.value);
66
+ let value;
67
+ try {
68
+ value = await this.request(input);
69
+ }
70
+ catch (error) {
71
+ if (error instanceof MessageReadersError && error.forbidden) {
72
+ this.revoked = true;
73
+ this.generation++;
74
+ await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
75
+ throw error;
76
+ }
77
+ if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
78
+ return structuredClone(cached.value);
79
+ throw error;
80
+ }
81
+ if (generation !== this.generation)
82
+ throw new Error("MESSAGE_READERS_INVALIDATED");
83
+ this.revoked = false;
84
+ if (value.resetRequired)
85
+ await cache?.invalidate().catch(error => this.options.onError?.(error));
86
+ else if (stored)
87
+ await cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
88
+ throw error; this.options.onError?.(error); });
89
+ if (this.lifetime.signal.aborted)
90
+ throw new Error("MESSAGE_READERS_CLOSED");
91
+ return value;
92
+ }
40
93
  async request(input) {
41
94
  const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
42
- const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
43
- method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
44
- signal: AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(8000)]), redirect: "error", cache: "no-store",
45
- });
46
- if (!response.ok)
47
- throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
48
- const envelope = await response.json(), data = envelope.data;
49
- if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
50
- || !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
51
- || !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
52
- || data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
53
- || typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
54
- || (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
55
- throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
56
- return data;
95
+ const controller = new AbortController();
96
+ const cancel = () => controller.abort(this.lifetime.signal.reason);
97
+ this.lifetime.signal.addEventListener("abort", cancel, { once: true });
98
+ const timeout = setTimeout(() => controller.abort(), 8000);
99
+ try {
100
+ const response = await (fetcher ?? fetch)(`${hubHttpUrl.replace(/^ws/, "http").replace(/\/+$/, "")}/api/social/reading/readers`, {
101
+ method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ ...input, appId, profileId }),
102
+ signal: controller.signal, redirect: "error", cache: "no-store",
103
+ });
104
+ if (!response.ok)
105
+ throw new MessageReadersError(response.status, `MESSAGE_READERS_HTTP_${response.status}`);
106
+ const envelope = await response.json(), data = envelope.data;
107
+ if (envelope.success !== true || data?.appId !== appId || data.threadId !== input.threadId || data.messageId !== input.messageId
108
+ || !Number.isSafeInteger(data.revision) || data.revision < 0 || !Number.isSafeInteger(data.total) || data.total < 0
109
+ || !Array.isArray(data.profileIds) || data.profileIds.length > input.limit || data.profileIds.length > data.total
110
+ || data.profileIds.some((id, i, ids) => typeof id !== "string" || !id || id.length > 512 || id === profileId || (i > 0 && id <= ids[i - 1]) || (input.afterProfileId && !data.resetRequired && id <= input.afterProfileId))
111
+ || typeof data.resetRequired !== "boolean" || (data.nextAfter !== null && (typeof data.nextAfter !== "string" || data.nextAfter !== data.profileIds.at(-1)))
112
+ || (!data.resetRequired && input.revision !== undefined && data.revision !== input.revision))
113
+ throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
114
+ if (this.lifetime.signal.aborted)
115
+ throw new Error("MESSAGE_READERS_CLOSED");
116
+ return data;
117
+ }
118
+ finally {
119
+ clearTimeout(timeout);
120
+ this.lifetime.signal.removeEventListener("abort", cancel);
121
+ }
57
122
  }
58
- close() { this.lifetime.abort(); this.pending.clear(); }
123
+ close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
59
124
  }
@@ -15,6 +15,9 @@ export type MessageCoverage = {
15
15
  checkpoint?: string;
16
16
  complete: boolean;
17
17
  nextBefore?: string | null;
18
+ syncedAt?: number;
19
+ resetVersion?: number;
20
+ revoked?: boolean;
18
21
  };
19
22
  type CoverageRow = MessageCoverage & {
20
23
  scope: string;
@@ -33,6 +36,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
33
36
  indexedDB?: IDBFactory;
34
37
  name?: string;
35
38
  });
39
+ get namespace(): string;
36
40
  read(thread: string, limit?: number, before?: {
37
41
  timestamp: number;
38
42
  id: string;
@@ -41,7 +45,13 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
41
45
  hasEarlier: boolean;
42
46
  coverage: CoverageRow | undefined;
43
47
  }>;
44
- commit(thread: string, messages: T[], coverage?: MessageCoverage): Promise<number>;
48
+ commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
49
+ version: number;
50
+ authoritative?: boolean;
51
+ }): Promise<number>;
52
+ windows(): Promise<CoverageRow[]>;
53
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
54
+ resetThread(thread: string, revoked?: boolean): Promise<number>;
45
55
  removeThread(thread: string): Promise<void>;
46
56
  clear(): Promise<void>;
47
57
  close(): void;
@@ -17,6 +17,7 @@ export class IndexedDbMessageStore {
17
17
  this.coverage = this.db.table("coverage");
18
18
  this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
19
19
  }
20
+ get namespace() { return this.db.name; }
20
21
  async read(thread, limit = 100, before) {
21
22
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
22
23
  throw new Error("INVALID_MESSAGE_LIMIT");
@@ -26,13 +27,18 @@ export class IndexedDbMessageStore {
26
27
  return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
27
28
  });
28
29
  }
29
- async commit(thread, messages, coverage) {
30
+ async commit(thread, messages, coverage, guard) {
30
31
  for (const message of messages)
31
32
  if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
32
33
  throw new Error("MESSAGE_SCOPE_MISMATCH");
33
34
  if (messages.length > 1000)
34
35
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
35
36
  return this.db.transaction("rw", this.rows, this.coverage, async () => {
37
+ const current = await this.coverage.get([this.key, thread]);
38
+ if (guard && (current?.resetVersion ?? 0) !== guard.version)
39
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
40
+ if (guard && current?.revoked && !guard.authoritative)
41
+ throw new Error("MESSAGE_ACCESS_REVOKED");
36
42
  const ids = [...new Set(messages.map(message => message.id))];
37
43
  const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
38
44
  const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
@@ -48,13 +54,26 @@ export class IndexedDbMessageStore {
48
54
  if (changed.size)
49
55
  await this.rows.bulkPut([...changed.values()]);
50
56
  if (coverage) {
51
- const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
57
+ const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
52
58
  if (JSON.stringify(old) !== JSON.stringify(next))
53
59
  await this.coverage.put(next);
54
60
  }
55
61
  return changed.size;
56
62
  });
57
63
  }
64
+ async windows() {
65
+ return this.coverage.where("scope").equals(this.key).toArray();
66
+ }
67
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
68
+ async resetThread(thread, revoked = false) {
69
+ return this.db.transaction("rw", this.rows, this.coverage, async () => {
70
+ const old = await this.coverage.get([this.key, thread]);
71
+ const resetVersion = (old?.resetVersion ?? 0) + 1;
72
+ await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
73
+ await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
74
+ return resetVersion;
75
+ });
76
+ }
58
77
  async removeThread(thread) {
59
78
  await this.db.transaction("rw", this.rows, this.coverage, async () => {
60
79
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
@@ -1,3 +1,4 @@
1
+ import { type MessageScope } from "./messageStore.js";
1
2
  export type ReadCountsResult = {
2
3
  appId: string;
3
4
  threadId: string;
@@ -11,6 +12,7 @@ export type ReadCountsOptions = {
11
12
  clientInstanceId: string;
12
13
  hubHttpUrl: string;
13
14
  fetch?: typeof fetch;
15
+ scope?: MessageScope;
14
16
  onChange: (value: ReadCountsResult) => void;
15
17
  onError?: (error: unknown) => void;
16
18
  };
@@ -25,13 +27,22 @@ export declare class ReadCountsClient {
25
27
  private generation;
26
28
  private retries;
27
29
  private closed;
30
+ private closing?;
28
31
  private revision;
29
32
  private running;
30
33
  private dirty;
31
34
  private subscribed;
32
35
  private subscriptionDenied;
33
36
  private subscriptionPending?;
37
+ private cache?;
38
+ private lastResult?;
39
+ private publish;
40
+ private revoked;
41
+ private accessVersion;
42
+ private requestedRevision;
43
+ private force;
34
44
  constructor(options: ReadCountsOptions);
45
+ private denyCache;
35
46
  private post;
36
47
  private subscription;
37
48
  private renew;
@@ -40,5 +51,5 @@ export declare class ReadCountsClient {
40
51
  reconnect(): void;
41
52
  private schedule;
42
53
  private refresh;
43
- close(): void;
54
+ close(): Promise<void>;
44
55
  }
@@ -1,3 +1,5 @@
1
+ import { IndexedDbResponseCache } from "./responseCache.js";
2
+ import { messageScopeKey } from "./messageStore.js";
1
3
  let subscriptionSequence = 0;
2
4
  function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
3
5
  /** One active conversation; fixed event window, bounded batches, no count polling. */
@@ -11,14 +13,38 @@ export class ReadCountsClient {
11
13
  generation = 0;
12
14
  retries = 0;
13
15
  closed = false;
16
+ closing;
14
17
  revision = -1;
15
18
  running = false;
16
19
  dirty = false;
17
20
  subscribed = false;
18
21
  subscriptionDenied = false;
19
22
  subscriptionPending;
23
+ cache;
24
+ lastResult;
25
+ publish(value) {
26
+ if (JSON.stringify(value) === JSON.stringify(this.lastResult))
27
+ return;
28
+ this.lastResult = value;
29
+ this.options.onChange(value);
30
+ }
31
+ revoked = false;
32
+ accessVersion = 0;
33
+ requestedRevision = -1;
34
+ force = false;
20
35
  constructor(options) {
21
36
  this.options = options;
37
+ if (options.scope) {
38
+ if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
39
+ throw new Error("READ_COUNTS_SCOPE_MISMATCH");
40
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
41
+ }
42
+ }
43
+ denyCache() {
44
+ this.revoked = true;
45
+ this.accessVersion++;
46
+ this.lastResult = undefined;
47
+ void this.cache?.invalidate().catch(error => this.options.onError?.(error));
22
48
  }
23
49
  async post(path, body, signal) {
24
50
  const response = await (this.options.fetch ?? fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, { method: "POST", headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" }, body: JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000), redirect: "error" });
@@ -33,7 +59,7 @@ export class ReadCountsClient {
33
59
  return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
34
60
  }
35
61
  renew() {
36
- if (!this.thread || this.closed || this.subscriptionDenied)
62
+ if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
37
63
  return;
38
64
  const threadId = this.thread, generation = this.generation;
39
65
  if (this.subscriptionPending?.generation === generation)
@@ -56,6 +82,7 @@ export class ReadCountsClient {
56
82
  this.subscribed = false;
57
83
  if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
58
84
  this.subscriptionDenied = true;
85
+ this.denyCache();
59
86
  clearInterval(this.heartbeat);
60
87
  this.heartbeat = undefined;
61
88
  }
@@ -83,9 +110,12 @@ export class ReadCountsClient {
83
110
  clearTimeout(this.timer);
84
111
  clearInterval(this.heartbeat);
85
112
  this.timer = undefined;
113
+ this.lastResult = undefined;
86
114
  this.thread = threadId;
87
115
  this.ids = ids;
88
116
  this.revision = -1;
117
+ this.requestedRevision = -1;
118
+ this.force = false;
89
119
  this.dirty = false;
90
120
  this.retries = 0;
91
121
  this.subscribed = false;
@@ -104,11 +134,13 @@ export class ReadCountsClient {
104
134
  throw new Error("INVALID_READ_COUNTS_EVENT");
105
135
  if (threadId !== this.thread || revision <= this.revision || this.closed)
106
136
  return;
137
+ this.requestedRevision = Math.max(this.requestedRevision, revision);
107
138
  this.schedule(3000);
108
139
  }
109
140
  reconnect() {
110
141
  if (!this.thread || this.closed)
111
142
  return;
143
+ this.force = true;
112
144
  this.subscribed = false;
113
145
  this.subscriptionDenied = false;
114
146
  this.renew();
@@ -137,20 +169,44 @@ export class ReadCountsClient {
137
169
  let retryDelay;
138
170
  const controller = new AbortController();
139
171
  this.controller = controller;
172
+ const accessVersion = this.accessVersion, force = this.force;
173
+ this.force = false;
174
+ const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
140
175
  try {
176
+ const stored = this.cache ? await this.cache.read(key).catch(error => { this.options.onError?.(error); return undefined; }) : undefined;
177
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
178
+ return;
179
+ const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
180
+ if (cached) {
181
+ this.revision = cached.value.revision;
182
+ this.publish(cached.value);
183
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
184
+ if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
185
+ return;
186
+ }
141
187
  const value = await this.post("/api/social/reading/counts", { appId: this.options.appId, profileId: this.options.profileId, threadId, messageIds: ids }, controller.signal);
142
- if (generation !== this.generation || this.closed)
188
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
143
189
  return;
144
190
  if (value?.appId !== this.options.appId || value.threadId !== threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !value.counts || typeof value.counts !== "object" || ids.some(id => !Number.isSafeInteger(value.counts[id]) || value.counts[id] < 0) || Object.keys(value.counts).some(id => !ids.includes(id)))
145
191
  throw new Error("INVALID_READ_COUNTS_RESULT");
192
+ if (value.revision < this.revision)
193
+ return;
146
194
  this.retries = 0;
195
+ this.revoked = false;
196
+ if (stored)
197
+ await this.cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
198
+ throw error; this.options.onError?.(error); });
199
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
200
+ return;
147
201
  this.revision = value.revision;
148
- this.options.onChange(value);
202
+ this.publish(value);
149
203
  if (!this.subscribed)
150
204
  this.renew();
151
205
  }
152
206
  catch (error) {
153
207
  if (!controller.signal.aborted && generation === this.generation && !this.closed) {
208
+ if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
209
+ this.denyCache();
154
210
  this.options.onError?.(error);
155
211
  if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
156
212
  retryDelay = 1000 * 2 ** this.retries++;
@@ -167,8 +223,8 @@ export class ReadCountsClient {
167
223
  }
168
224
  }
169
225
  close() {
170
- if (this.closed)
171
- return;
226
+ if (this.closing)
227
+ return this.closing;
172
228
  const old = this.thread;
173
229
  this.closed = true;
174
230
  this.generation++;
@@ -178,5 +234,6 @@ export class ReadCountsClient {
178
234
  if (old)
179
235
  void this.subscription(old, true).catch(() => { });
180
236
  this.thread = undefined;
237
+ return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
181
238
  }
182
239
  }
@@ -84,11 +84,15 @@ export interface ReadIndexPersistence {
84
84
  }
85
85
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
86
86
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
87
+ private storageScope?;
88
+ private migrateLegacy;
87
89
  private database;
88
90
  private profiles;
89
91
  private closed;
90
- constructor(factory?: IDBFactory, name?: string);
92
+ constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
91
93
  private transaction;
94
+ private key;
95
+ private loadCurrent;
92
96
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
93
97
  update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
94
98
  close(): Promise<void>;
@@ -166,10 +166,14 @@ export function unreadInThread(state, threadId) {
166
166
  }
167
167
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
168
168
  export class IndexedDbReadIndex {
169
+ storageScope;
170
+ migrateLegacy;
169
171
  database;
170
172
  profiles;
171
173
  closed = false;
172
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index") {
174
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
175
+ this.storageScope = storageScope;
176
+ this.migrateLegacy = migrateLegacy;
173
177
  if (!factory)
174
178
  throw new Error("READ_STORAGE_UNAVAILABLE");
175
179
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
@@ -202,17 +206,37 @@ export class IndexedDbReadIndex {
202
206
  }
203
207
  throw new Error("READ_STORAGE_UNAVAILABLE");
204
208
  }
209
+ key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
210
+ async loadCurrent(appId, profileId) {
211
+ const current = await this.profiles.get(this.key(appId, profileId));
212
+ if (current || !this.migrateLegacy || this.storageScope === undefined)
213
+ return current;
214
+ const markerKey = [appId, profileId, "legacy-owner-v1"];
215
+ const marker = await this.profiles.get(markerKey);
216
+ if (marker && marker.legacyOwner !== this.storageScope)
217
+ return undefined;
218
+ const legacy = await this.profiles.get([appId, profileId]);
219
+ if (!legacy)
220
+ return undefined;
221
+ if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
222
+ throw new Error("READ_IDENTITY_MISMATCH");
223
+ const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
224
+ await this.profiles.put(ownership, markerKey);
225
+ await this.profiles.put(legacy, this.key(appId, profileId));
226
+ await this.profiles.delete([appId, profileId]);
227
+ return legacy;
228
+ }
205
229
  async load(appId, profileId) {
206
- return this.transaction("r", () => this.profiles.get([appId, profileId]));
230
+ return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
207
231
  }
208
232
  async update(appId, profileId, transform) {
209
233
  return this.transaction("rw", async () => {
210
- const previous = await this.profiles.get([appId, profileId]);
234
+ const previous = await this.loadCurrent(appId, profileId);
211
235
  const next = transform(previous ?? createReadIndex(appId, profileId));
212
236
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
213
237
  throw new Error("READ_IDENTITY_MISMATCH");
214
238
  if (JSON.stringify(next) !== JSON.stringify(previous))
215
- await this.profiles.put(next, [appId, profileId]);
239
+ await this.profiles.put(next, this.key(appId, profileId));
216
240
  return next;
217
241
  });
218
242
  }
@@ -5,6 +5,8 @@ export type ReadingClientOptions = {
5
5
  sessionToken: string;
6
6
  hubHttpUrl: string;
7
7
  persistence?: ReadIndexPersistence;
8
+ storageScope?: string;
9
+ migrateLegacy?: boolean;
8
10
  fetch?: typeof fetch;
9
11
  onError?: (error: unknown) => void;
10
12
  onRecovered?: () => void;
@@ -19,7 +19,7 @@ export class ReadingClient {
19
19
  generation = 0;
20
20
  constructor(options) {
21
21
  this.options = options;
22
- const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex());
22
+ const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
23
23
  this.persistence = new BatchedReadIndex(storage);
24
24
  this.tasks = new WindowedTasks(1000, error => this.failed(error));
25
25
  this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
@@ -5,6 +5,8 @@ export type RecordCacheOptions<T> = {
5
5
  indexedDB?: IDBFactory;
6
6
  name?: string;
7
7
  onError?: (error: unknown) => void;
8
+ transactionBytes?: number;
9
+ queueBytes?: number;
8
10
  };
9
11
  /** In-memory control-event index backed by bounded asynchronous IndexedDB batches.
10
12
  * Legacy storage is read only during open; the hot path never uses localStorage. */
@@ -23,6 +25,11 @@ export declare class IndexedDbRecordCache<T> {
23
25
  private closing;
24
26
  private initialized;
25
27
  private running?;
28
+ private activeBytes;
29
+ private pendingBytes;
30
+ private activeRecords;
31
+ private transactionBytes;
32
+ private queueBytes;
26
33
  private channel?;
27
34
  private listeners;
28
35
  constructor(options: RecordCacheOptions<T>);
@@ -34,7 +41,9 @@ export declare class IndexedDbRecordCache<T> {
34
41
  subscribe(listener: () => void): () => void;
35
42
  private reload;
36
43
  upsert(incoming: T[]): Promise<void>;
44
+ private bytes;
37
45
  private write;
46
+ private writeBatch;
38
47
  flush(): Promise<void>;
39
48
  private runFlush;
40
49
  close(): Promise<void>;