@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.21

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 { type ResponseCacheAccess } from "./responseCache.js";
2
+ import { type MessageScope } from "./messageStore.js";
1
3
  export type MessageReadersPage = {
2
4
  appId: string;
3
5
  threadId: string;
@@ -21,6 +23,9 @@ export type MessageReadersOptions = {
21
23
  token: string;
22
24
  hubHttpUrl: string;
23
25
  fetch?: typeof fetch;
26
+ scope?: MessageScope;
27
+ access?: ResponseCacheAccess;
28
+ onError?: (error: unknown) => void;
24
29
  };
25
30
  export declare class MessageReadersError extends Error {
26
31
  readonly status: number;
@@ -32,8 +37,14 @@ export declare class MessageReadersClient {
32
37
  private options;
33
38
  private pending;
34
39
  private lifetime;
40
+ private cache?;
41
+ private revoked;
42
+ private generation;
35
43
  constructor(options: MessageReadersOptions);
36
44
  getReaders(query: MessageReadersQuery, signal?: AbortSignal): Promise<MessageReadersPage>;
45
+ private cachedRequest;
37
46
  private request;
38
- close(): void;
47
+ withdraw(): void;
48
+ retireScope(version: number): Promise<void>;
49
+ close(): Promise<void>;
39
50
  }
@@ -1,3 +1,5 @@
1
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } 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), { access: options.access });
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,7 +49,52 @@ 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 => { if (isResponseCacheAuthorityError(error))
60
+ throw error; this.options.onError?.(error); return undefined; });
61
+ if (this.lifetime.signal.aborted)
62
+ throw new Error("MESSAGE_READERS_CLOSED");
63
+ const generation = this.generation;
64
+ const cached = this.revoked ? undefined : stored?.cached;
65
+ if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
66
+ return structuredClone(cached.value);
67
+ let value;
68
+ try {
69
+ value = await this.request(input);
70
+ }
71
+ catch (error) {
72
+ if (error instanceof MessageReadersError && error.forbidden) {
73
+ this.revoked = true;
74
+ this.generation++;
75
+ await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
76
+ throw error;
77
+ }
78
+ if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
79
+ return structuredClone(cached.value);
80
+ throw error;
81
+ }
82
+ if (generation !== this.generation)
83
+ throw new Error("MESSAGE_READERS_INVALIDATED");
84
+ this.revoked = false;
85
+ if (value.resetRequired)
86
+ await cache?.invalidate().catch(error => this.options.onError?.(error));
87
+ else if (stored)
88
+ await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
89
+ throw error; this.options.onError?.(error); });
90
+ if (this.lifetime.signal.aborted)
91
+ throw new Error("MESSAGE_READERS_CLOSED");
92
+ return value;
93
+ }
40
94
  async request(input) {
95
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
96
+ if (this.lifetime.signal.aborted)
97
+ throw new Error("MESSAGE_READERS_CLOSED");
41
98
  const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
42
99
  const controller = new AbortController();
43
100
  const cancel = () => controller.abort(this.lifetime.signal.reason);
@@ -60,6 +117,8 @@ export class MessageReadersClient {
60
117
  throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
61
118
  if (this.lifetime.signal.aborted)
62
119
  throw new Error("MESSAGE_READERS_CLOSED");
120
+ if (this.options.access)
121
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
63
122
  return data;
64
123
  }
65
124
  finally {
@@ -67,5 +126,7 @@ export class MessageReadersClient {
67
126
  this.lifetime.signal.removeEventListener("abort", cancel);
68
127
  }
69
128
  }
70
- close() { this.lifetime.abort(); this.pending.clear(); }
129
+ withdraw() { this.lifetime.abort(); this.pending.clear(); }
130
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
131
+ close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
71
132
  }
@@ -15,6 +15,15 @@ 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;
21
+ scopeVersion?: number;
22
+ };
23
+ export type MessageScopeAccess = {
24
+ scope: string;
25
+ version: number;
26
+ revoked: boolean;
18
27
  };
19
28
  type CoverageRow = MessageCoverage & {
20
29
  scope: string;
@@ -27,21 +36,47 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
27
36
  private merge;
28
37
  private db;
29
38
  private rows;
39
+ private legacy;
30
40
  private coverage;
41
+ private accessRows;
31
42
  private key;
32
43
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
33
44
  indexedDB?: IDBFactory;
34
45
  name?: string;
35
46
  });
47
+ get namespace(): string;
36
48
  read(thread: string, limit?: number, before?: {
37
49
  timestamp: number;
38
50
  id: string;
39
51
  }): Promise<{
40
52
  messages: T[];
41
53
  hasEarlier: boolean;
42
- coverage: CoverageRow | undefined;
54
+ coverage: MessageCoverage | undefined;
55
+ }>;
56
+ /** Migration records never claim coverage or overwrite authoritative rows. */
57
+ importLegacy(thread: string, messages: T[]): Promise<number>;
58
+ /** Bounded fallback only; callers must report an incomplete window. */
59
+ readFallback(thread: string, limit?: number, before?: {
60
+ timestamp: number;
61
+ id: string;
62
+ }): Promise<{
63
+ messages: T[];
64
+ hasEarlier: boolean;
65
+ coverage: MessageCoverage | undefined;
43
66
  }>;
44
- commit(thread: string, messages: T[], coverage?: MessageCoverage): Promise<number>;
67
+ commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
68
+ version: number;
69
+ authoritative?: boolean;
70
+ scopeVersion?: number;
71
+ }): Promise<number>;
72
+ windows(): Promise<CoverageRow[]>;
73
+ scopeAccess(): Promise<MessageScopeAccess>;
74
+ private scopedCoverage;
75
+ private clearScopeRows;
76
+ /** Persist the denial first; failed cleanup must never make cached rows readable. */
77
+ revokeScope(expectedVersion?: number): Promise<number>;
78
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
79
+ resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
45
80
  removeThread(thread: string): Promise<void>;
46
81
  clear(): Promise<void>;
47
82
  close(): void;
@@ -6,63 +6,203 @@ export class IndexedDbMessageStore {
6
6
  merge;
7
7
  db;
8
8
  rows;
9
+ legacy;
9
10
  coverage;
11
+ accessRows;
10
12
  key;
11
13
  constructor(scope, merge, options = {}) {
12
14
  this.merge = merge;
13
15
  this.key = messageScopeKey(scope);
14
16
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
15
17
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
18
+ this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
19
+ this.db.version(3).stores({ scopeAccess: "scope" });
20
+ this.accessRows = this.db.table("scopeAccess");
21
+ this.legacy = this.db.table("legacyMessages");
16
22
  this.rows = this.db.table("messages");
17
23
  this.coverage = this.db.table("coverage");
18
24
  this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
19
25
  }
26
+ get namespace() { return this.db.name; }
20
27
  async read(thread, limit = 100, before) {
21
28
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
22
29
  throw new Error("INVALID_MESSAGE_LIMIT");
23
- return this.db.transaction("r", this.rows, this.coverage, async () => {
30
+ return this.db.transaction("r", this.rows, this.coverage, this.accessRows, async () => {
31
+ const access = await this.scopeAccess();
32
+ if (access.revoked)
33
+ return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
24
34
  const range = this.rows.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before);
25
35
  const records = await range.reverse().limit(limit + 1).toArray();
26
- return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
36
+ return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: this.scopedCoverage(await this.coverage.get([this.key, thread]), access) };
27
37
  });
28
38
  }
29
- async commit(thread, messages, coverage) {
39
+ /** Migration records never claim coverage or overwrite authoritative rows. */
40
+ async importLegacy(thread, messages) {
41
+ if (messages.length > 128)
42
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
43
+ let bytes = 0;
44
+ for (const message of messages) {
45
+ if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
46
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
47
+ bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
48
+ if (bytes > 1_048_576)
49
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
50
+ }
51
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
52
+ const access = await this.scopeAccess();
53
+ const coverage = await this.coverage.get([this.key, thread]);
54
+ if (access.revoked || coverage?.revoked)
55
+ throw new Error("MESSAGE_ACCESS_REVOKED");
56
+ if (access.version > 0)
57
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
58
+ if ((coverage?.resetVersion ?? 0) > 0)
59
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
60
+ const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
61
+ const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
62
+ const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
63
+ const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
64
+ for (const message of messages) {
65
+ if (known.has(message.id))
66
+ continue;
67
+ const old = latest.get(message.id), value = this.merge(old, message);
68
+ if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
69
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
70
+ latest.set(message.id, value);
71
+ if (JSON.stringify(old) !== JSON.stringify(value))
72
+ changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
73
+ }
74
+ if (changed.size)
75
+ await this.legacy.bulkPut([...changed.values()]);
76
+ return changed.size;
77
+ });
78
+ }
79
+ /** Bounded fallback only; callers must report an incomplete window. */
80
+ async readFallback(thread, limit = 100, before) {
81
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
82
+ throw new Error("INVALID_MESSAGE_LIMIT");
83
+ return this.db.transaction("r", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
84
+ const access = await this.scopeAccess();
85
+ const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
86
+ if (coverage?.revoked)
87
+ return { messages: [], hasEarlier: false, coverage };
88
+ const read = (table) => table.where("[scope+thread+time+id]").between([this.key, thread, Dexie.minKey, Dexie.minKey], [this.key, thread, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
89
+ const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
90
+ const rows = [...new Map([...legacy, ...canonical].map(row => [row.id, row])).values()].sort((a, b) => a.time - b.time || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
91
+ return { messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage };
92
+ });
93
+ }
94
+ async commit(thread, messages, coverage, guard) {
30
95
  for (const message of messages)
31
96
  if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
32
97
  throw new Error("MESSAGE_SCOPE_MISMATCH");
33
98
  if (messages.length > 1000)
34
99
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
35
- return this.db.transaction("rw", this.rows, this.coverage, async () => {
100
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
101
+ const access = await this.scopeAccess();
102
+ if ((guard?.scopeVersion ?? 0) !== access.version)
103
+ throw new Error("MESSAGE_SCOPE_CHANGED");
104
+ if (access.revoked) {
105
+ if (!guard?.authoritative)
106
+ throw new Error("MESSAGE_ACCESS_REVOKED");
107
+ await this.clearScopeRows();
108
+ await this.accessRows.put({ ...access, revoked: false });
109
+ }
110
+ const current = await this.coverage.get([this.key, thread]);
111
+ if (guard && (current?.resetVersion ?? 0) !== guard.version)
112
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
113
+ if (guard && current?.revoked && !guard.authoritative)
114
+ throw new Error("MESSAGE_ACCESS_REVOKED");
36
115
  const ids = [...new Set(messages.map(message => message.id))];
37
- const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
38
- const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
116
+ const keys = ids.map(id => [this.key, thread, id]);
117
+ const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
118
+ const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
119
+ const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
39
120
  const changed = new Map();
40
121
  for (const message of messages) {
41
122
  const old = latest.get(message.id), value = this.merge(old, message);
42
123
  if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
43
124
  throw new Error("MESSAGE_SCOPE_MISMATCH");
44
125
  latest.set(message.id, value);
45
- if (JSON.stringify(old) !== JSON.stringify(value))
126
+ if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
46
127
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
47
128
  }
48
129
  if (changed.size)
49
130
  await this.rows.bulkPut([...changed.values()]);
131
+ const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
132
+ if (replaced.length)
133
+ await this.legacy.bulkDelete(replaced);
50
134
  if (coverage) {
51
- const old = await this.coverage.get([this.key, thread]), next = { ...coverage, scope: this.key, thread };
135
+ const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
52
136
  if (JSON.stringify(old) !== JSON.stringify(next))
53
137
  await this.coverage.put(next);
54
138
  }
55
139
  return changed.size;
56
140
  });
57
141
  }
142
+ async windows() {
143
+ return this.db.transaction("r", this.coverage, this.accessRows, async () => {
144
+ if ((await this.scopeAccess()).revoked)
145
+ return [];
146
+ return this.coverage.where("scope").equals(this.key).toArray();
147
+ });
148
+ }
149
+ async scopeAccess() {
150
+ const access = (await this.accessRows.get(this.key)) ?? { scope: this.key, version: 0, revoked: false };
151
+ if (!Number.isSafeInteger(access.version) || access.version < 0 || typeof access.revoked !== "boolean")
152
+ throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
153
+ return access;
154
+ }
155
+ scopedCoverage(coverage, access) {
156
+ return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
157
+ }
158
+ async clearScopeRows() {
159
+ await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
160
+ await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
161
+ await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
162
+ }
163
+ /** Persist the denial first; failed cleanup must never make cached rows readable. */
164
+ async revokeScope(expectedVersion) {
165
+ const access = await this.db.transaction("rw", this.accessRows, async () => {
166
+ const old = await this.scopeAccess();
167
+ if (expectedVersion !== undefined && old.version !== expectedVersion)
168
+ return old;
169
+ const next = { scope: this.key, version: old.version + 1, revoked: true };
170
+ await this.accessRows.put(next);
171
+ return next;
172
+ });
173
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
174
+ const current = await this.scopeAccess();
175
+ if (current.version === access.version && current.revoked)
176
+ await this.clearScopeRows();
177
+ });
178
+ return access.version;
179
+ }
180
+ /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
181
+ async resetThread(thread, revoked = false, scopeVersion = 0) {
182
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
183
+ const access = await this.scopeAccess();
184
+ if (access.version !== scopeVersion)
185
+ throw new Error("MESSAGE_SCOPE_CHANGED");
186
+ if (access.revoked)
187
+ throw new Error("MESSAGE_ACCESS_REVOKED");
188
+ const old = await this.coverage.get([this.key, thread]);
189
+ const resetVersion = (old?.resetVersion ?? 0) + 1;
190
+ await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
191
+ await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
192
+ await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
193
+ return resetVersion;
194
+ });
195
+ }
58
196
  async removeThread(thread) {
59
- await this.db.transaction("rw", this.rows, this.coverage, async () => {
197
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
60
198
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
199
+ await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
61
200
  await this.coverage.delete([this.key, thread]);
62
201
  });
63
202
  }
64
203
  async clear() {
65
- await this.db.transaction("rw", this.rows, this.coverage, async () => {
204
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
205
+ await this.legacy.where("scope").equals(this.key).delete();
66
206
  await this.rows.where("scope").equals(this.key).delete();
67
207
  await this.coverage.where("scope").equals(this.key).delete();
68
208
  });
@@ -1,3 +1,5 @@
1
+ import { type ResponseCacheAccess } from "./responseCache.js";
2
+ import { type MessageScope } from "./messageStore.js";
1
3
  export type ReadCountsResult = {
2
4
  appId: string;
3
5
  threadId: string;
@@ -11,6 +13,8 @@ export type ReadCountsOptions = {
11
13
  clientInstanceId: string;
12
14
  hubHttpUrl: string;
13
15
  fetch?: typeof fetch;
16
+ scope?: MessageScope;
17
+ access?: ResponseCacheAccess;
14
18
  onChange: (value: ReadCountsResult) => void;
15
19
  onError?: (error: unknown) => void;
16
20
  };
@@ -25,13 +29,22 @@ export declare class ReadCountsClient {
25
29
  private generation;
26
30
  private retries;
27
31
  private closed;
32
+ private closing?;
28
33
  private revision;
29
34
  private running;
30
35
  private dirty;
31
36
  private subscribed;
32
37
  private subscriptionDenied;
33
38
  private subscriptionPending?;
39
+ private cache?;
40
+ private lastResult?;
41
+ private publish;
42
+ private revoked;
43
+ private accessVersion;
44
+ private requestedRevision;
45
+ private force;
34
46
  constructor(options: ReadCountsOptions);
47
+ private denyCache;
35
48
  private post;
36
49
  private subscription;
37
50
  private renew;
@@ -40,5 +53,6 @@ export declare class ReadCountsClient {
40
53
  reconnect(): void;
41
54
  private schedule;
42
55
  private refresh;
43
- close(): void;
56
+ retireScope(version: number): Promise<void>;
57
+ close(): Promise<void>;
44
58
  }
@@ -1,3 +1,5 @@
1
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } 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,29 +13,56 @@ 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), { access: options.access });
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) {
50
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
24
51
  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" });
25
52
  if (!response.ok)
26
53
  throw new Error(`READ_COUNTS_HTTP_${response.status}`);
27
54
  const envelope = await response.json();
28
55
  if (envelope.success !== true)
29
56
  throw new Error("INVALID_READ_COUNTS_RESPONSE");
57
+ if (this.options.access)
58
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
30
59
  return envelope.data;
31
60
  }
32
61
  subscription(threadId, release) {
33
62
  return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
34
63
  }
35
64
  renew() {
36
- if (!this.thread || this.closed || this.subscriptionDenied)
65
+ if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
37
66
  return;
38
67
  const threadId = this.thread, generation = this.generation;
39
68
  if (this.subscriptionPending?.generation === generation)
@@ -48,13 +77,23 @@ export class ReadCountsClient {
48
77
  this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
49
78
  this.renew(); }, 20_000);
50
79
  // Recover changes that occurred before a receiver/subscription became ready.
51
- if (recovered && this.revision >= 0)
80
+ if (recovered) {
81
+ // A fresh cache predates the new lease and cannot cover its delivery gap.
82
+ this.force = true;
52
83
  this.schedule(0);
84
+ }
53
85
  }).catch(error => {
54
86
  if (this.closed || generation !== this.generation)
55
87
  return;
56
88
  this.subscribed = false;
57
89
  if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
90
+ this.subscriptionDenied = true;
91
+ this.denyCache();
92
+ clearInterval(this.heartbeat);
93
+ this.heartbeat = undefined;
94
+ }
95
+ if (isResponseCacheAuthorityError(error)) {
96
+ this.lastResult = undefined;
58
97
  this.subscriptionDenied = true;
59
98
  clearInterval(this.heartbeat);
60
99
  this.heartbeat = undefined;
@@ -83,9 +122,12 @@ export class ReadCountsClient {
83
122
  clearTimeout(this.timer);
84
123
  clearInterval(this.heartbeat);
85
124
  this.timer = undefined;
125
+ this.lastResult = undefined;
86
126
  this.thread = threadId;
87
127
  this.ids = ids;
88
128
  this.revision = -1;
129
+ this.requestedRevision = -1;
130
+ this.force = false;
89
131
  this.dirty = false;
90
132
  this.retries = 0;
91
133
  this.subscribed = false;
@@ -104,11 +146,13 @@ export class ReadCountsClient {
104
146
  throw new Error("INVALID_READ_COUNTS_EVENT");
105
147
  if (threadId !== this.thread || revision <= this.revision || this.closed)
106
148
  return;
149
+ this.requestedRevision = Math.max(this.requestedRevision, revision);
107
150
  this.schedule(3000);
108
151
  }
109
152
  reconnect() {
110
153
  if (!this.thread || this.closed)
111
154
  return;
155
+ this.force = true;
112
156
  this.subscribed = false;
113
157
  this.subscriptionDenied = false;
114
158
  this.renew();
@@ -137,22 +181,54 @@ export class ReadCountsClient {
137
181
  let retryDelay;
138
182
  const controller = new AbortController();
139
183
  this.controller = controller;
184
+ const accessVersion = this.accessVersion, force = this.force;
185
+ this.force = false;
186
+ const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
140
187
  try {
188
+ const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
189
+ throw error; this.options.onError?.(error); return undefined; }) : undefined;
190
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
191
+ return;
192
+ const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
193
+ if (cached) {
194
+ this.revision = cached.value.revision;
195
+ this.publish(cached.value);
196
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
197
+ if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
198
+ return;
199
+ }
141
200
  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)
201
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
143
202
  return;
144
203
  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
204
  throw new Error("INVALID_READ_COUNTS_RESULT");
205
+ if (value.revision < this.revision)
206
+ return;
146
207
  this.retries = 0;
208
+ this.revoked = false;
209
+ if (stored)
210
+ await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
211
+ throw error; this.options.onError?.(error); });
212
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
213
+ return;
147
214
  this.revision = value.revision;
148
- this.options.onChange(value);
215
+ this.publish(value);
149
216
  if (!this.subscribed)
150
217
  this.renew();
151
218
  }
152
219
  catch (error) {
153
220
  if (!controller.signal.aborted && generation === this.generation && !this.closed) {
221
+ if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
222
+ this.denyCache();
223
+ if (isResponseCacheAuthorityError(error)) {
224
+ this.lastResult = undefined;
225
+ this.subscriptionDenied = true;
226
+ this.dirty = false;
227
+ clearInterval(this.heartbeat);
228
+ this.heartbeat = undefined;
229
+ }
154
230
  this.options.onError?.(error);
155
- if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
231
+ if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
156
232
  retryDelay = 1000 * 2 ** this.retries++;
157
233
  this.dirty = true;
158
234
  }
@@ -166,9 +242,10 @@ export class ReadCountsClient {
166
242
  }
167
243
  }
168
244
  }
245
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
169
246
  close() {
170
- if (this.closed)
171
- return;
247
+ if (this.closing)
248
+ return this.closing;
172
249
  const old = this.thread;
173
250
  this.closed = true;
174
251
  this.generation++;
@@ -178,5 +255,6 @@ export class ReadCountsClient {
178
255
  if (old)
179
256
  void this.subscription(old, true).catch(() => { });
180
257
  this.thread = undefined;
258
+ return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
181
259
  }
182
260
  }