@pmate/chat-sdk 0.12.0-next.20 → 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.
@@ -8,6 +8,7 @@ export class IndexedDbMessageStore {
8
8
  rows;
9
9
  legacy;
10
10
  coverage;
11
+ accessRows;
11
12
  key;
12
13
  constructor(scope, merge, options = {}) {
13
14
  this.merge = merge;
@@ -15,6 +16,8 @@ export class IndexedDbMessageStore {
15
16
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
16
17
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
17
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");
18
21
  this.legacy = this.db.table("legacyMessages");
19
22
  this.rows = this.db.table("messages");
20
23
  this.coverage = this.db.table("coverage");
@@ -24,10 +27,13 @@ export class IndexedDbMessageStore {
24
27
  async read(thread, limit = 100, before) {
25
28
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
26
29
  throw new Error("INVALID_MESSAGE_LIMIT");
27
- 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 } };
28
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);
29
35
  const records = await range.reverse().limit(limit + 1).toArray();
30
- 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) };
31
37
  });
32
38
  }
33
39
  /** Migration records never claim coverage or overwrite authoritative rows. */
@@ -42,10 +48,13 @@ export class IndexedDbMessageStore {
42
48
  if (bytes > 1_048_576)
43
49
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
44
50
  }
45
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
51
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
52
+ const access = await this.scopeAccess();
46
53
  const coverage = await this.coverage.get([this.key, thread]);
47
- if (coverage?.revoked)
54
+ if (access.revoked || coverage?.revoked)
48
55
  throw new Error("MESSAGE_ACCESS_REVOKED");
56
+ if (access.version > 0)
57
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
49
58
  if ((coverage?.resetVersion ?? 0) > 0)
50
59
  throw new Error("MESSAGE_PROJECTION_CHANGED");
51
60
  const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
@@ -71,8 +80,9 @@ export class IndexedDbMessageStore {
71
80
  async readFallback(thread, limit = 100, before) {
72
81
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
73
82
  throw new Error("INVALID_MESSAGE_LIMIT");
74
- return this.db.transaction("r", this.rows, this.legacy, this.coverage, async () => {
75
- const coverage = await this.coverage.get([this.key, thread]);
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);
76
86
  if (coverage?.revoked)
77
87
  return { messages: [], hasEarlier: false, coverage };
78
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();
@@ -87,7 +97,16 @@ export class IndexedDbMessageStore {
87
97
  throw new Error("MESSAGE_SCOPE_MISMATCH");
88
98
  if (messages.length > 1000)
89
99
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
90
- return this.db.transaction("rw", this.rows, this.legacy, 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
+ }
91
110
  const current = await this.coverage.get([this.key, thread]);
92
111
  if (guard && (current?.resetVersion ?? 0) !== guard.version)
93
112
  throw new Error("MESSAGE_PROJECTION_CHANGED");
@@ -109,8 +128,9 @@ export class IndexedDbMessageStore {
109
128
  }
110
129
  if (changed.size)
111
130
  await this.rows.bulkPut([...changed.values()]);
112
- if (ids.length)
113
- await this.legacy.bulkDelete(ids.map(id => [this.key, thread, id]));
131
+ const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
132
+ if (replaced.length)
133
+ await this.legacy.bulkDelete(replaced);
114
134
  if (coverage) {
115
135
  const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
116
136
  if (JSON.stringify(old) !== JSON.stringify(next))
@@ -120,11 +140,51 @@ export class IndexedDbMessageStore {
120
140
  });
121
141
  }
122
142
  async windows() {
123
- return this.coverage.where("scope").equals(this.key).toArray();
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;
124
179
  }
125
180
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
126
- async resetThread(thread, revoked = false) {
127
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
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");
128
188
  const old = await this.coverage.get([this.key, thread]);
129
189
  const resetVersion = (old?.resetVersion ?? 0) + 1;
130
190
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
@@ -134,14 +194,14 @@ export class IndexedDbMessageStore {
134
194
  });
135
195
  }
136
196
  async removeThread(thread) {
137
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
197
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
138
198
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
139
199
  await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
140
200
  await this.coverage.delete([this.key, thread]);
141
201
  });
142
202
  }
143
203
  async clear() {
144
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
204
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
145
205
  await this.legacy.where("scope").equals(this.key).delete();
146
206
  await this.rows.where("scope").equals(this.key).delete();
147
207
  await this.coverage.where("scope").equals(this.key).delete();
@@ -1,3 +1,4 @@
1
+ import { type ResponseCacheAccess } from "./responseCache.js";
1
2
  import { type MessageScope } from "./messageStore.js";
2
3
  export type ReadCountsResult = {
3
4
  appId: string;
@@ -13,6 +14,7 @@ export type ReadCountsOptions = {
13
14
  hubHttpUrl: string;
14
15
  fetch?: typeof fetch;
15
16
  scope?: MessageScope;
17
+ access?: ResponseCacheAccess;
16
18
  onChange: (value: ReadCountsResult) => void;
17
19
  onError?: (error: unknown) => void;
18
20
  };
@@ -51,5 +53,6 @@ export declare class ReadCountsClient {
51
53
  reconnect(): void;
52
54
  private schedule;
53
55
  private refresh;
56
+ retireScope(version: number): Promise<void>;
54
57
  close(): Promise<void>;
55
58
  }
@@ -1,4 +1,4 @@
1
- import { IndexedDbResponseCache } from "./responseCache.js";
1
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
2
2
  import { messageScopeKey } from "./messageStore.js";
3
3
  let subscriptionSequence = 0;
4
4
  function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
@@ -37,7 +37,7 @@ export class ReadCountsClient {
37
37
  if (options.scope) {
38
38
  if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
39
39
  throw new Error("READ_COUNTS_SCOPE_MISMATCH");
40
- this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope));
40
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
41
41
  }
42
42
  }
43
43
  denyCache() {
@@ -47,12 +47,15 @@ export class ReadCountsClient {
47
47
  void this.cache?.invalidate().catch(error => this.options.onError?.(error));
48
48
  }
49
49
  async post(path, body, signal) {
50
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
50
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" });
51
52
  if (!response.ok)
52
53
  throw new Error(`READ_COUNTS_HTTP_${response.status}`);
53
54
  const envelope = await response.json();
54
55
  if (envelope.success !== true)
55
56
  throw new Error("INVALID_READ_COUNTS_RESPONSE");
57
+ if (this.options.access)
58
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
56
59
  return envelope.data;
57
60
  }
58
61
  subscription(threadId, release) {
@@ -89,6 +92,12 @@ export class ReadCountsClient {
89
92
  clearInterval(this.heartbeat);
90
93
  this.heartbeat = undefined;
91
94
  }
95
+ if (isResponseCacheAuthorityError(error)) {
96
+ this.lastResult = undefined;
97
+ this.subscriptionDenied = true;
98
+ clearInterval(this.heartbeat);
99
+ this.heartbeat = undefined;
100
+ }
92
101
  this.options.onError?.(error);
93
102
  }).finally(() => { if (this.subscriptionPending === pending)
94
103
  this.subscriptionPending = undefined; });
@@ -176,7 +185,8 @@ export class ReadCountsClient {
176
185
  this.force = false;
177
186
  const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
178
187
  try {
179
- const stored = this.cache ? await this.cache.read(key).catch(error => { this.options.onError?.(error); return undefined; }) : undefined;
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;
180
190
  if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
181
191
  return;
182
192
  const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
@@ -197,7 +207,7 @@ export class ReadCountsClient {
197
207
  this.retries = 0;
198
208
  this.revoked = false;
199
209
  if (stored)
200
- await this.cache?.write(key, value, stored.epoch).catch(error => { if (String(error).includes("RESPONSE_CACHE_INVALIDATED"))
210
+ await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
201
211
  throw error; this.options.onError?.(error); });
202
212
  if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
203
213
  return;
@@ -210,8 +220,15 @@ export class ReadCountsClient {
210
220
  if (!controller.signal.aborted && generation === this.generation && !this.closed) {
211
221
  if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
212
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
+ }
213
230
  this.options.onError?.(error);
214
- 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) {
215
232
  retryDelay = 1000 * 2 ** this.retries++;
216
233
  this.dirty = true;
217
234
  }
@@ -225,6 +242,7 @@ export class ReadCountsClient {
225
242
  }
226
243
  }
227
244
  }
245
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
228
246
  close() {
229
247
  if (this.closing)
230
248
  return this.closing;
@@ -77,10 +77,22 @@ export declare function unreadInThread(state: ReadIndexState, threadId: string):
77
77
  unreadMentionCount: number;
78
78
  badgeCount: number;
79
79
  };
80
+ export type ReadIndexVersions = Readonly<Record<string, number>>;
81
+ export type ReadIndexTransform = (current: ReadIndexState, versions?: ReadIndexVersions) => ReadIndexState;
82
+ export declare function assertReadIndexVersions(expected: ReadIndexVersions, current?: ReadIndexVersions): void;
83
+ /** Remove only the withdrawn team's original sources, logical conversations and
84
+ * queued observations. Other teams' pending operations must survive. */
85
+ export declare function removeReadTeam(state: ReadIndexState, teamId: string, knownThreadIds?: readonly string[]): ReadIndexState;
80
86
  export interface ReadIndexPersistence {
81
87
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
88
+ loadVersioned?(appId: string, profileId: string): Promise<{
89
+ state: ReadIndexState | undefined;
90
+ versions: ReadIndexVersions;
91
+ }>;
92
+ loadVersions?(appId: string, profileId: string): Promise<ReadIndexVersions>;
93
+ invalidateTeam?(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
82
94
  /** Transform and persist atomically; UI publishes only the committed result. */
83
- update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
95
+ update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
84
96
  }
85
97
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
86
98
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
@@ -88,13 +100,20 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
88
100
  private migrateLegacy;
89
101
  private database;
90
102
  private profiles;
103
+ private versions;
91
104
  private closed;
92
105
  constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
93
106
  private transaction;
94
107
  private key;
95
108
  private loadCurrent;
96
109
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
97
- update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
110
+ loadVersioned(appId: string, profileId: string): Promise<{
111
+ state: ReadIndexState | undefined;
112
+ versions: Record<string, number>;
113
+ }>;
114
+ loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
115
+ invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
116
+ update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
98
117
  close(): Promise<void>;
99
118
  }
100
119
  export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
@@ -164,12 +164,33 @@ export function unreadInThread(state, threadId) {
164
164
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
165
165
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
166
166
  }
167
+ function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
168
+ export function assertReadIndexVersions(expected, current = {}) {
169
+ const teams = new Set([...Object.keys(expected), ...Object.keys(current)]);
170
+ if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
171
+ throw new Error("READ_SCOPE_CHANGED");
172
+ }
173
+ /** Remove only the withdrawn team's original sources, logical conversations and
174
+ * queued observations. Other teams' pending operations must survive. */
175
+ export function removeReadTeam(state, teamId, knownThreadIds = []) {
176
+ const removed = new Set(knownThreadIds);
177
+ for (const thread of state.threads)
178
+ if (thread.teamId === teamId) {
179
+ removed.add(thread.id);
180
+ if (thread.conversationId)
181
+ removed.add(thread.conversationId);
182
+ }
183
+ return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
184
+ pending: state.pending.filter(operation => !removed.has(operation.threadId)),
185
+ observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
186
+ }
167
187
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
168
188
  export class IndexedDbReadIndex {
169
189
  storageScope;
170
190
  migrateLegacy;
171
191
  database;
172
192
  profiles;
193
+ versions;
173
194
  closed = false;
174
195
  constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
175
196
  this.storageScope = storageScope;
@@ -179,7 +200,9 @@ export class IndexedDbReadIndex {
179
200
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
180
201
  // The empty schema retains the legacy out-of-line primary key and records.
181
202
  this.database.version(1).stores({ profiles: "" });
203
+ this.database.version(2).stores({ profiles: "", versions: "" });
182
204
  this.profiles = this.database.table("profiles");
205
+ this.versions = this.database.table("versions");
183
206
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
184
207
  }
185
208
  async transaction(mode, execute) {
@@ -188,7 +211,7 @@ export class IndexedDbReadIndex {
188
211
  throw new Error("READ_STORAGE_CLOSED");
189
212
  let started = false;
190
213
  try {
191
- return await this.database.transaction(mode, this.profiles, async () => {
214
+ return await this.database.transaction(mode, this.profiles, this.versions, async () => {
192
215
  if (this.closed)
193
216
  throw new Error("READ_STORAGE_CLOSED");
194
217
  started = true;
@@ -229,10 +252,32 @@ export class IndexedDbReadIndex {
229
252
  async load(appId, profileId) {
230
253
  return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
231
254
  }
255
+ async loadVersioned(appId, profileId) {
256
+ return this.transaction(this.migrateLegacy ? "rw" : "r", async () => ({
257
+ state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
258
+ }));
259
+ }
260
+ async loadVersions(appId, profileId) {
261
+ return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
262
+ }
263
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
264
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
265
+ throw new Error("INVALID_READ_SCOPE_VERSION");
266
+ return this.transaction("rw", async () => {
267
+ const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
268
+ const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
269
+ if (readTeamVersion(teams, teamId) >= version)
270
+ return state;
271
+ const next = removeReadTeam(state, teamId, knownThreadIds);
272
+ await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
273
+ await this.profiles.put(next, this.key(appId, profileId));
274
+ return next;
275
+ });
276
+ }
232
277
  async update(appId, profileId, transform) {
233
278
  return this.transaction("rw", async () => {
234
279
  const previous = await this.loadCurrent(appId, profileId);
235
- const next = transform(previous ?? createReadIndex(appId, profileId));
280
+ const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
236
281
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
237
282
  throw new Error("READ_IDENTITY_MISMATCH");
238
283
  if (JSON.stringify(next) !== JSON.stringify(previous))
@@ -1,4 +1,4 @@
1
- import { applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
1
+ import { assertReadIndexVersions, applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
2
2
  /** Keeps local operations until an authoritative projection covers them. */
3
3
  export class ReadSyncClient {
4
4
  options;
@@ -46,7 +46,9 @@ export class ReadSyncClient {
46
46
  }
47
47
  async run() {
48
48
  const { appId, profileId, persistence } = this.options;
49
- const current = await this.loadLocal();
49
+ const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
50
+ const current = loaded.state ?? createReadIndex(appId, profileId);
51
+ const versions = loaded.versions;
50
52
  // Ordinary reads are monotonic: only the furthest position per thread needs
51
53
  // sending. Keep every original operation in storage until the projection
52
54
  // confirms it, and never rewrite an operation ID's payload during retries.
@@ -60,6 +62,8 @@ export class ReadSyncClient {
60
62
  }
61
63
  const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
62
64
  for (const operation of outgoing) {
65
+ if (persistence.loadVersions)
66
+ assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
63
67
  const receipt = await this.request("/api/social/reading", {
64
68
  method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
65
69
  });
@@ -78,7 +82,8 @@ export class ReadSyncClient {
78
82
  try {
79
83
  if (this.controller.signal.aborted)
80
84
  throw new Error("READ_SYNC_STOPPED");
81
- const next = await persistence.update(appId, profileId, state => {
85
+ const next = await persistence.update(appId, profileId, (state, currentVersions) => {
86
+ assertReadIndexVersions(versions, currentVersions);
82
87
  const applied = delta.changes.reduce(applyReadChange, state);
83
88
  if (applied.syncCursor !== delta.syncCursor)
84
89
  throw new ReadSyncGap(applied.syncCursor);
@@ -100,7 +105,10 @@ export class ReadSyncClient {
100
105
  if (this.controller.signal.aborted)
101
106
  throw new Error("READ_SYNC_STOPPED");
102
107
  // Merge into the latest transaction, including reads made while HTTP was pending.
103
- const next = await persistence.update(appId, profileId, state => installReadSnapshot(state, snapshot));
108
+ const next = await persistence.update(appId, profileId, (state, currentVersions) => {
109
+ assertReadIndexVersions(versions, currentVersions);
110
+ return installReadSnapshot(state, snapshot);
111
+ });
104
112
  if (!next.pending.length)
105
113
  this.readAfter = 0;
106
114
  if (!this.controller.signal.aborted)
@@ -17,6 +17,11 @@ export type ReadingClientOptions = {
17
17
  export declare class ReadingClient {
18
18
  private options;
19
19
  private storage;
20
+ private backing;
21
+ private closing?;
22
+ private revoking?;
23
+ private versions?;
24
+ private accepted;
20
25
  private persistence;
21
26
  private sync;
22
27
  private tasks;
@@ -36,9 +41,15 @@ export declare class ReadingClient {
36
41
  refresh: () => void;
37
42
  synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
38
43
  update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
44
+ prepareTeam(teamId: string, version: number): Promise<void>;
39
45
  markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
40
46
  recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
41
47
  recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
42
48
  private record;
49
+ withdraw(): void;
50
+ private stop;
51
+ /** This client is retired permanently. Drain accepted local observations
52
+ * before deleting one team's profile-wide projection; sibling teams survive. */
53
+ revokeTeam(teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<void>;
43
54
  close(): Promise<void>;
44
55
  }
@@ -1,13 +1,18 @@
1
1
  import { recordConversationRead } from "./conversationRead.js";
2
2
  import { BatchedReadIndex } from "./batchedReadIndex.js";
3
3
  import { WindowedTasks } from "./coalescer.js";
4
- import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
4
+ import { IndexedDbReadIndex, recordReadIntent, assertReadIndexVersions } from "./readIndex.js";
5
5
  import { ReadSyncClient } from "./readSync.js";
6
6
  /** Owns durable local reads, network coalescing, retries and state notifications.
7
7
  * Hosts report actual visibility; loading or subscribing never marks messages read. */
8
8
  export class ReadingClient {
9
9
  options;
10
10
  storage;
11
+ backing;
12
+ closing;
13
+ revoking;
14
+ versions;
15
+ accepted = new Set();
11
16
  persistence;
12
17
  sync;
13
18
  tasks;
@@ -21,6 +26,7 @@ export class ReadingClient {
21
26
  constructor(options) {
22
27
  this.options = options;
23
28
  const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
29
+ this.backing = storage;
24
30
  this.persistence = new BatchedReadIndex(storage);
25
31
  this.tasks = new WindowedTasks(1000, error => this.failed(error));
26
32
  this.sync = new ReadSyncClient({ ...options, persistence: this.persistence, onChange: state => this.publish(state) });
@@ -85,16 +91,37 @@ export class ReadingClient {
85
91
  }
86
92
  throw new Error("Reading projection has not reached the announced version");
87
93
  }
88
- async update(transform) {
94
+ update(transform) {
95
+ if (this.closed)
96
+ return Promise.reject(new Error("READING_CLIENT_CLOSED"));
97
+ // One shared metadata read per local authority generation, rather than one
98
+ // IndexedDB lookup for every viewport observation.
99
+ const versions = this.versions ??= this.persistence.loadVersions(this.options.appId, this.options.profileId);
100
+ const task = (async () => {
101
+ const expected = await versions;
102
+ const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
103
+ assertReadIndexVersions(expected, currentVersions);
104
+ return transform(state);
105
+ });
106
+ if (!this.closed) {
107
+ this.publish(next);
108
+ if (next.pending.length || next.observedReadIntents?.length)
109
+ this.refresh();
110
+ }
111
+ return next;
112
+ })().catch(error => { if (String(error).includes("READ_SCOPE_CHANGED"))
113
+ this.versions = undefined; throw error; });
114
+ this.accepted.add(task);
115
+ void task.finally(() => this.accepted.delete(task)).catch(() => { });
116
+ return task;
117
+ }
118
+ async prepareTeam(teamId, version) {
89
119
  if (this.closed)
90
120
  throw new Error("READING_CLIENT_CLOSED");
91
- const next = await this.persistence.update(this.options.appId, this.options.profileId, transform);
92
- if (!this.closed) {
93
- this.publish(next);
94
- if (next.pending.length || next.observedReadIntents?.length)
95
- this.refresh();
121
+ if (version > 0) {
122
+ await this.persistence.invalidateTeam(this.options.appId, this.options.profileId, teamId, version);
123
+ this.versions = undefined;
96
124
  }
97
- return next;
98
125
  }
99
126
  markRead(threadId, messages = []) {
100
127
  return this.update(state => recordConversationRead(state, threadId, messages));
@@ -105,15 +132,55 @@ export class ReadingClient {
105
132
  const messageIds = [...new Set(ids)];
106
133
  return this.update(state => messageIds.reduce((next, messageId) => recordReadIntent(next, { threadId, messageId, readPolicy, operationId: crypto.randomUUID() }), state));
107
134
  }
108
- async close() {
135
+ withdraw() { this.stop(); }
136
+ stop() {
137
+ if (this.closed)
138
+ return;
109
139
  this.closed = true;
110
140
  this.generation++;
111
141
  this.offWake?.();
112
142
  this.tasks.close();
113
143
  clearTimeout(this.retry);
114
144
  this.sync.stop();
145
+ this.state = undefined;
146
+ this.listeners.forEach(listener => listener());
115
147
  this.listeners.clear();
116
- await this.persistence.close();
117
- await this.storage?.close();
148
+ }
149
+ /** This client is retired permanently. Drain accepted local observations
150
+ * before deleting one team's profile-wide projection; sibling teams survive. */
151
+ revokeTeam(teamId, version, knownThreadIds = []) {
152
+ if (this.revoking)
153
+ return this.revoking;
154
+ if (this.closing)
155
+ return Promise.reject(new Error("READING_CLIENT_CLOSED"));
156
+ this.stop();
157
+ return this.revoking = (async () => {
158
+ try {
159
+ await Promise.allSettled([...this.accepted]);
160
+ await this.persistence.close();
161
+ if (!this.backing.invalidateTeam)
162
+ throw new Error("READ_SCOPE_INVALIDATION_UNAVAILABLE");
163
+ await this.backing.invalidateTeam(this.options.appId, this.options.profileId, teamId, version, knownThreadIds);
164
+ }
165
+ finally {
166
+ await this.storage?.close();
167
+ }
168
+ })();
169
+ }
170
+ close() {
171
+ if (this.revoking)
172
+ return this.revoking;
173
+ if (this.closing)
174
+ return this.closing;
175
+ this.stop();
176
+ return this.closing = (async () => {
177
+ try {
178
+ await Promise.allSettled([...this.accepted]);
179
+ await this.persistence.close();
180
+ }
181
+ finally {
182
+ await this.storage?.close();
183
+ }
184
+ })();
118
185
  }
119
186
  }
@@ -1,7 +1,14 @@
1
+ export type ResponseCacheAccess = () => Promise<{
2
+ version: number;
3
+ allowed: boolean;
4
+ }>;
5
+ export declare function isResponseCacheAuthorityError(error: unknown): boolean;
6
+ export declare function assertResponseCacheAccess(access?: ResponseCacheAccess, expectedVersion?: number): Promise<number | undefined>;
1
7
  /** Small response windows, isolated by identity. Epochs fence late writes after
2
8
  * access withdrawal. No localStorage or synchronous writes on the read path. */
3
9
  export declare class IndexedDbResponseCache<T> {
4
10
  private scope;
11
+ private options;
5
12
  private db;
6
13
  private rows;
7
14
  private epochs;
@@ -14,7 +21,12 @@ export declare class IndexedDbResponseCache<T> {
14
21
  constructor(scope: string, options?: {
15
22
  indexedDB?: IDBFactory;
16
23
  name?: string;
24
+ access?: ResponseCacheAccess;
17
25
  });
26
+ /** Resolve shared message-scope authority before opening our transaction.
27
+ * A newer authority version retires response windows exactly once; delayed
28
+ * callers can never roll the authority backwards or delete recovered data. */
29
+ private synchronizeAccess;
18
30
  read(key: string): Promise<{
19
31
  epoch: number;
20
32
  cached: {
@@ -23,6 +35,9 @@ export declare class IndexedDbResponseCache<T> {
23
35
  } | undefined;
24
36
  }>;
25
37
  write(key: string, value: T, epoch: number): Promise<void>;
38
+ /** Idempotent shared-scope retirement: an old cleanup cannot erase windows
39
+ * created after the same authority version was already recovered. */
40
+ retire(version: number): Promise<void>;
26
41
  invalidate(): Promise<void>;
27
42
  flush(): Promise<void>;
28
43
  close(): Promise<void>;