@pmate/chat-sdk 0.12.0-next.3 → 0.12.0-next.31

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.
Files changed (41) hide show
  1. package/README.md +41 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +11 -2
  3. package/lib/runtime/batchedReadIndex.js +21 -1
  4. package/lib/runtime/chatClient.d.ts +1 -0
  5. package/lib/runtime/chatClient.js +1 -1
  6. package/lib/runtime/chatSession.d.ts +78 -8
  7. package/lib/runtime/chatSession.js +488 -39
  8. package/lib/runtime/connectivity.d.ts +4 -0
  9. package/lib/runtime/connectivity.js +8 -0
  10. package/lib/runtime/conversationRead.d.ts +13 -0
  11. package/lib/runtime/conversationRead.js +34 -0
  12. package/lib/runtime/conversationReading.d.ts +28 -0
  13. package/lib/runtime/conversationReading.js +43 -0
  14. package/lib/runtime/createChat.d.ts +2 -0
  15. package/lib/runtime/createChat.js +18 -5
  16. package/lib/runtime/durableSender.d.ts +28 -4
  17. package/lib/runtime/durableSender.js +159 -35
  18. package/lib/runtime/index.d.ts +3 -0
  19. package/lib/runtime/index.js +3 -0
  20. package/lib/runtime/messageClient.d.ts +31 -6
  21. package/lib/runtime/messageClient.js +405 -70
  22. package/lib/runtime/messageReaders.d.ts +12 -1
  23. package/lib/runtime/messageReaders.js +67 -3
  24. package/lib/runtime/messageStore.d.ts +37 -2
  25. package/lib/runtime/messageStore.js +150 -10
  26. package/lib/runtime/messageTimeline.d.ts +47 -0
  27. package/lib/runtime/messageTimeline.js +113 -0
  28. package/lib/runtime/readCounts.d.ts +15 -1
  29. package/lib/runtime/readCounts.js +96 -8
  30. package/lib/runtime/readIndex.d.ts +32 -3
  31. package/lib/runtime/readIndex.js +203 -8
  32. package/lib/runtime/readSync.js +12 -4
  33. package/lib/runtime/readingClient.d.ts +18 -0
  34. package/lib/runtime/readingClient.js +120 -18
  35. package/lib/runtime/readingContent.d.ts +2 -0
  36. package/lib/runtime/readingContent.js +9 -0
  37. package/lib/runtime/recordCache.d.ts +9 -0
  38. package/lib/runtime/recordCache.js +62 -5
  39. package/lib/runtime/responseCache.d.ts +44 -0
  40. package/lib/runtime/responseCache.js +173 -0
  41. package/package.json +1 -1
@@ -1,3 +1,6 @@
1
+ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
2
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
3
+ import { messageScopeKey } from "./messageStore.js";
1
4
  let subscriptionSequence = 0;
2
5
  function nextSubscriptionSequence() { subscriptionSequence = Math.max(Date.now() * 1000, subscriptionSequence + 1); return subscriptionSequence; }
3
6
  /** One active conversation; fixed event window, bounded batches, no count polling. */
@@ -11,29 +14,58 @@ export class ReadCountsClient {
11
14
  generation = 0;
12
15
  retries = 0;
13
16
  closed = false;
17
+ closing;
14
18
  revision = -1;
15
19
  running = false;
16
20
  dirty = false;
17
21
  subscribed = false;
18
22
  subscriptionDenied = false;
19
23
  subscriptionPending;
24
+ cache;
25
+ lastResult;
26
+ publish(value) {
27
+ if (JSON.stringify(value) === JSON.stringify(this.lastResult))
28
+ return;
29
+ this.lastResult = value;
30
+ this.options.onChange(value);
31
+ }
32
+ revoked = false;
33
+ accessVersion = 0;
34
+ requestedRevision = -1;
35
+ force = false;
20
36
  constructor(options) {
21
37
  this.options = options;
38
+ if (options.scope) {
39
+ if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
40
+ throw new Error("READ_COUNTS_SCOPE_MISMATCH");
41
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
42
+ }
43
+ }
44
+ denyCache() {
45
+ this.revoked = true;
46
+ this.accessVersion++;
47
+ this.lastResult = undefined;
48
+ void this.cache?.invalidate().catch(error => this.options.onError?.(error));
22
49
  }
23
50
  async post(path, body, signal) {
51
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
24
52
  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
53
  if (!response.ok)
26
54
  throw new Error(`READ_COUNTS_HTTP_${response.status}`);
27
55
  const envelope = await response.json();
28
56
  if (envelope.success !== true)
29
57
  throw new Error("INVALID_READ_COUNTS_RESPONSE");
58
+ if (this.options.access)
59
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
30
60
  return envelope.data;
31
61
  }
32
62
  subscription(threadId, release) {
63
+ if (release && isOffline())
64
+ return Promise.resolve();
33
65
  return this.post("/api/social/reading/subscription", { appId: this.options.appId, profileId: this.options.profileId, threadId, clientInstanceId: this.options.clientInstanceId, sequence: nextSubscriptionSequence(), release });
34
66
  }
35
67
  renew() {
36
- if (!this.thread || this.closed || this.subscriptionDenied)
68
+ if (!this.thread || this.closed || this.subscriptionDenied || (typeof navigator !== "undefined" && navigator.onLine === false))
37
69
  return;
38
70
  const threadId = this.thread, generation = this.generation;
39
71
  if (this.subscriptionPending?.generation === generation)
@@ -48,18 +80,29 @@ export class ReadCountsClient {
48
80
  this.heartbeat = setInterval(() => { if (!this.closed && this.thread === threadId)
49
81
  this.renew(); }, 20_000);
50
82
  // Recover changes that occurred before a receiver/subscription became ready.
51
- if (recovered && this.revision >= 0)
83
+ if (recovered) {
84
+ // A fresh cache predates the new lease and cannot cover its delivery gap.
85
+ this.force = true;
52
86
  this.schedule(0);
87
+ }
53
88
  }).catch(error => {
54
89
  if (this.closed || generation !== this.generation)
55
90
  return;
56
91
  this.subscribed = false;
57
92
  if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
58
93
  this.subscriptionDenied = true;
94
+ this.denyCache();
59
95
  clearInterval(this.heartbeat);
60
96
  this.heartbeat = undefined;
61
97
  }
62
- this.options.onError?.(error);
98
+ if (isResponseCacheAuthorityError(error)) {
99
+ this.lastResult = undefined;
100
+ this.subscriptionDenied = true;
101
+ clearInterval(this.heartbeat);
102
+ this.heartbeat = undefined;
103
+ }
104
+ if (!isOfflineNetworkFailure(error))
105
+ this.options.onError?.(error);
63
106
  }).finally(() => { if (this.subscriptionPending === pending)
64
107
  this.subscriptionPending = undefined; });
65
108
  this.subscriptionPending = pending;
@@ -83,9 +126,12 @@ export class ReadCountsClient {
83
126
  clearTimeout(this.timer);
84
127
  clearInterval(this.heartbeat);
85
128
  this.timer = undefined;
129
+ this.lastResult = undefined;
86
130
  this.thread = threadId;
87
131
  this.ids = ids;
88
132
  this.revision = -1;
133
+ this.requestedRevision = -1;
134
+ this.force = false;
89
135
  this.dirty = false;
90
136
  this.retries = 0;
91
137
  this.subscribed = false;
@@ -104,11 +150,13 @@ export class ReadCountsClient {
104
150
  throw new Error("INVALID_READ_COUNTS_EVENT");
105
151
  if (threadId !== this.thread || revision <= this.revision || this.closed)
106
152
  return;
153
+ this.requestedRevision = Math.max(this.requestedRevision, revision);
107
154
  this.schedule(3000);
108
155
  }
109
156
  reconnect() {
110
157
  if (!this.thread || this.closed)
111
158
  return;
159
+ this.force = true;
112
160
  this.subscribed = false;
113
161
  this.subscriptionDenied = false;
114
162
  this.renew();
@@ -137,22 +185,60 @@ export class ReadCountsClient {
137
185
  let retryDelay;
138
186
  const controller = new AbortController();
139
187
  this.controller = controller;
188
+ const accessVersion = this.accessVersion, force = this.force;
189
+ this.force = false;
190
+ const key = JSON.stringify(["counts", threadId, [...ids].sort()]);
140
191
  try {
192
+ const stored = this.cache ? await this.cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
193
+ throw error; this.options.onError?.(error); return undefined; }) : undefined;
194
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
195
+ return;
196
+ const cached = this.revoked || (stored?.cached && stored.cached.value.revision < this.revision) ? undefined : stored?.cached;
197
+ if (cached) {
198
+ this.revision = cached.value.revision;
199
+ this.publish(cached.value);
200
+ const offline = typeof navigator !== "undefined" && navigator.onLine === false;
201
+ if (offline || (!force && this.requestedRevision <= this.revision && Date.now() - cached.time < 5000))
202
+ return;
203
+ }
204
+ if (isOffline())
205
+ return;
141
206
  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)
207
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
143
208
  return;
144
209
  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
210
  throw new Error("INVALID_READ_COUNTS_RESULT");
211
+ if (value.revision < this.revision)
212
+ return;
146
213
  this.retries = 0;
214
+ this.revoked = false;
215
+ if (stored)
216
+ await this.cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
217
+ throw error; this.options.onError?.(error); });
218
+ if (generation !== this.generation || this.closed || accessVersion !== this.accessVersion)
219
+ return;
147
220
  this.revision = value.revision;
148
- this.options.onChange(value);
221
+ this.publish(value);
149
222
  if (!this.subscribed)
150
223
  this.renew();
151
224
  }
152
225
  catch (error) {
226
+ if (isOfflineNetworkFailure(error)) {
227
+ this.dirty = false;
228
+ return;
229
+ }
153
230
  if (!controller.signal.aborted && generation === this.generation && !this.closed) {
231
+ if (/READ_COUNTS_HTTP_(401|403)/.test(String(error)))
232
+ this.denyCache();
233
+ if (isResponseCacheAuthorityError(error)) {
234
+ this.lastResult = undefined;
235
+ this.subscriptionDenied = true;
236
+ this.dirty = false;
237
+ clearInterval(this.heartbeat);
238
+ this.heartbeat = undefined;
239
+ }
154
240
  this.options.onError?.(error);
155
- if (!/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
241
+ if (!isResponseCacheAuthorityError(error) && !/READ_COUNTS_HTTP_4\d\d|INVALID_READ_COUNTS/.test(String(error)) && this.retries < 3) {
156
242
  retryDelay = 1000 * 2 ** this.retries++;
157
243
  this.dirty = true;
158
244
  }
@@ -166,9 +252,10 @@ export class ReadCountsClient {
166
252
  }
167
253
  }
168
254
  }
255
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
169
256
  close() {
170
- if (this.closed)
171
- return;
257
+ if (this.closing)
258
+ return this.closing;
172
259
  const old = this.thread;
173
260
  this.closed = true;
174
261
  this.generation++;
@@ -178,5 +265,6 @@ export class ReadCountsClient {
178
265
  if (old)
179
266
  void this.subscription(old, true).catch(() => { });
180
267
  this.thread = undefined;
268
+ return this.closing = this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve();
181
269
  }
182
270
  }
@@ -77,22 +77,51 @@ 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, teamId?: string): 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 {
87
99
  private storageScope?;
100
+ private migrateLegacy;
88
101
  private database;
89
102
  private profiles;
103
+ private threadRows;
104
+ private messageRows;
105
+ private positionRows;
106
+ private normalizedStates;
107
+ private versions;
90
108
  private closed;
91
- constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined);
109
+ constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
92
110
  private transaction;
93
111
  private key;
112
+ private hydrate;
113
+ private loadCurrent;
114
+ /** Message bodies are immutable for local read observations: keep them out of
115
+ * profile/cursor writes, and update only changed normalized rows on sync. */
116
+ private persist;
94
117
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
95
- update(appId: string, profileId: string, transform: (current: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
118
+ loadVersioned(appId: string, profileId: string): Promise<{
119
+ state: ReadIndexState | undefined;
120
+ versions: Record<string, number>;
121
+ }>;
122
+ loadVersions(appId: string, profileId: string): Promise<ReadIndexVersions>;
123
+ invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
124
+ update(appId: string, profileId: string, transform: ReadIndexTransform): Promise<ReadIndexState>;
96
125
  close(): Promise<void>;
97
126
  }
98
127
  export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
@@ -1,4 +1,5 @@
1
1
  import { Dexie } from "dexie";
2
+ import { isReadingControl } from "./readingContent.js";
2
3
  import { isValidChatTeamId } from "./thread.js";
3
4
  export class ReadSyncGap extends Error {
4
5
  cursor;
@@ -158,26 +159,59 @@ export function unreadInThread(state, threadId) {
158
159
  if (!thread)
159
160
  throw new Error("UNKNOWN_READ_THREAD");
160
161
  const explicit = new Set(thread.explicitReadMessageIds);
161
- const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
162
+ const messages = thread.messages.filter(message => !message.recalled && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
162
163
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
163
164
  const unreadMessageCount = messages.length;
164
165
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
165
166
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
166
167
  }
168
+ function readTeamVersion(versions, team) { return Object.hasOwn(versions, team) ? versions[team] : 0; }
169
+ export function assertReadIndexVersions(expected, current = {}, teamId) {
170
+ const teams = new Set(teamId === undefined ? [...Object.keys(expected), ...Object.keys(current)] : [teamId]);
171
+ if ([...teams].some(team => readTeamVersion(expected, team) !== readTeamVersion(current, team)))
172
+ throw new Error("READ_SCOPE_CHANGED");
173
+ }
174
+ /** Remove only the withdrawn team's original sources, logical conversations and
175
+ * queued observations. Other teams' pending operations must survive. */
176
+ export function removeReadTeam(state, teamId, knownThreadIds = []) {
177
+ const removed = new Set(knownThreadIds);
178
+ for (const thread of state.threads)
179
+ if (thread.teamId === teamId) {
180
+ removed.add(thread.id);
181
+ if (thread.conversationId)
182
+ removed.add(thread.conversationId);
183
+ }
184
+ return { ...state, syncCursor: "0", threads: state.threads.filter(thread => thread.teamId !== teamId && !removed.has(thread.id)),
185
+ pending: state.pending.filter(operation => !removed.has(operation.threadId)),
186
+ observedReadIntents: state.observedReadIntents?.filter(intent => !removed.has(intent.threadId)) };
187
+ }
167
188
  /** Dexie preserves the legacy database/store and outbound [appId, profileId] keys. */
168
189
  export class IndexedDbReadIndex {
169
190
  storageScope;
191
+ migrateLegacy;
170
192
  database;
171
193
  profiles;
194
+ threadRows;
195
+ messageRows;
196
+ positionRows;
197
+ normalizedStates = new WeakSet();
198
+ versions;
172
199
  closed = false;
173
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope) {
200
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
174
201
  this.storageScope = storageScope;
202
+ this.migrateLegacy = migrateLegacy;
175
203
  if (!factory)
176
204
  throw new Error("READ_STORAGE_UNAVAILABLE");
177
205
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
178
206
  // The empty schema retains the legacy out-of-line primary key and records.
179
207
  this.database.version(1).stores({ profiles: "" });
208
+ this.database.version(2).stores({ profiles: "", versions: "" });
209
+ this.database.version(3).stores({ readThreads: "[owner+id],owner", readMessages: "[owner+thread+id],[owner+thread],owner", readPositions: "[owner+id],owner" });
210
+ this.positionRows = this.database.table("readPositions");
211
+ this.threadRows = this.database.table("readThreads");
212
+ this.messageRows = this.database.table("readMessages");
180
213
  this.profiles = this.database.table("profiles");
214
+ this.versions = this.database.table("versions");
181
215
  this.database.on("versionchange", () => { this.database.close({ disableAutoOpen: false }); return false; });
182
216
  }
183
217
  async transaction(mode, execute) {
@@ -186,7 +220,7 @@ export class IndexedDbReadIndex {
186
220
  throw new Error("READ_STORAGE_CLOSED");
187
221
  let started = false;
188
222
  try {
189
- return await this.database.transaction(mode, this.profiles, async () => {
223
+ return await this.database.transaction(mode, [this.profiles, this.versions, this.threadRows, this.messageRows, this.positionRows], async () => {
190
224
  if (this.closed)
191
225
  throw new Error("READ_STORAGE_CLOSED");
192
226
  started = true;
@@ -205,17 +239,178 @@ export class IndexedDbReadIndex {
205
239
  throw new Error("READ_STORAGE_UNAVAILABLE");
206
240
  }
207
241
  key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
242
+ async hydrate(current, key) {
243
+ if (current.normalized) {
244
+ const owner = JSON.stringify(key);
245
+ const [threads, messages, positions] = await Promise.all([
246
+ this.threadRows.where("owner").equals(owner).toArray(),
247
+ this.messageRows.where("owner").equals(owner).toArray(),
248
+ this.positionRows.where("owner").equals(owner).toArray(),
249
+ ]);
250
+ const byThread = new Map();
251
+ for (const row of messages) {
252
+ if (!byThread.has(row.thread))
253
+ byThread.set(row.thread, new Map());
254
+ byThread.get(row.thread).set(row.id, row.value);
255
+ }
256
+ const byPosition = new Map(positions.map(row => [row.id, row]));
257
+ const byId = new Map(threads.map(row => [row.id, row]));
258
+ const { normalized: _normalized, threadOrder, ...state } = current;
259
+ const result = { ...state, threads: (threadOrder ?? []).map(id => {
260
+ const row = byId.get(id);
261
+ const position = byPosition.get(id);
262
+ if (!row || !position)
263
+ throw new Error("READ_STORAGE_INCOMPLETE");
264
+ return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages: row.messageIds.map(messageId => {
265
+ const message = byThread.get(id)?.get(messageId);
266
+ if (!message)
267
+ throw new Error("READ_STORAGE_INCOMPLETE");
268
+ return message;
269
+ }) };
270
+ }) };
271
+ this.normalizedStates.add(result);
272
+ return result;
273
+ }
274
+ return current;
275
+ }
276
+ async loadCurrent(appId, profileId) {
277
+ const current = await this.profiles.get(this.key(appId, profileId));
278
+ if (current?.normalized)
279
+ return this.hydrate(current, this.key(appId, profileId));
280
+ if (current) {
281
+ if (this.migrateLegacy)
282
+ await this.persist(appId, profileId, undefined, current, true);
283
+ return current;
284
+ }
285
+ if (!this.migrateLegacy || this.storageScope === undefined)
286
+ return undefined;
287
+ const markerKey = [appId, profileId, "legacy-owner-v1"];
288
+ const marker = await this.profiles.get(markerKey);
289
+ if (marker && marker.legacyOwner !== this.storageScope)
290
+ return undefined;
291
+ const legacyRow = await this.profiles.get([appId, profileId]);
292
+ if (!legacyRow)
293
+ return undefined;
294
+ const legacy = await this.hydrate(legacyRow, [appId, profileId]);
295
+ if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
296
+ throw new Error("READ_IDENTITY_MISMATCH");
297
+ const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
298
+ await this.profiles.put(ownership, markerKey);
299
+ await this.persist(appId, profileId, undefined, legacy);
300
+ await this.profiles.delete([appId, profileId]);
301
+ const oldOwner = JSON.stringify([appId, profileId]);
302
+ await this.threadRows.where("owner").equals(oldOwner).delete();
303
+ await this.messageRows.where("owner").equals(oldOwner).delete();
304
+ await this.positionRows.where("owner").equals(oldOwner).delete();
305
+ return legacy;
306
+ }
307
+ /** Message bodies are immutable for local read observations: keep them out of
308
+ * profile/cursor writes, and update only changed normalized rows on sync. */
309
+ async persist(appId, profileId, previous, next, replaceLegacy = false) {
310
+ if (previous && !this.normalizedStates.has(previous)) {
311
+ previous = undefined;
312
+ replaceLegacy = true;
313
+ }
314
+ const owner = JSON.stringify(this.key(appId, profileId));
315
+ const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
316
+ const nextIds = new Set(next.threads.map(thread => thread.id));
317
+ if (nextIds.size !== next.threads.length)
318
+ throw new Error("DUPLICATE_READ_THREAD");
319
+ const threadWrites = [], messageWrites = [], positionWrites = [];
320
+ const messageDeletes = [];
321
+ for (const thread of next.threads) {
322
+ const old = oldThreads.get(thread.id);
323
+ if (old === thread)
324
+ continue;
325
+ const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
326
+ const position = { owner, id: thread.id, readThroughSeq, explicitReadMessageIds };
327
+ if (!old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
328
+ positionWrites.push(position);
329
+ const oldValue = old ? (({ messages: _messages, readThroughSeq: _seq, explicitReadMessageIds: _explicit, ...rest }) => rest)(old) : undefined;
330
+ if (!old || old.messages !== messages || JSON.stringify(oldValue) !== JSON.stringify(value)) {
331
+ const metadata = { owner, id: thread.id, value, messageIds: messages.map(message => message.id) };
332
+ const oldMetadata = old ? { owner, id: old.id, value: oldValue, messageIds: old.messages.map(message => message.id) } : undefined;
333
+ if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
334
+ threadWrites.push(metadata);
335
+ }
336
+ if (old?.messages === messages)
337
+ continue;
338
+ const byId = new Map(old?.messages.map(message => [message.id, message]) ?? []);
339
+ const ids = new Set();
340
+ for (const message of messages) {
341
+ if (ids.has(message.id))
342
+ throw new Error("INVALID_READ_MESSAGE");
343
+ ids.add(message.id);
344
+ const prior = byId.get(message.id);
345
+ if (prior !== message && JSON.stringify(prior) !== JSON.stringify(message))
346
+ messageWrites.push({ owner, thread: thread.id, id: message.id, value: message });
347
+ }
348
+ for (const id of byId.keys())
349
+ if (!ids.has(id))
350
+ messageDeletes.push([owner, thread.id, id]);
351
+ }
352
+ for (const id of oldThreads.keys())
353
+ if (!nextIds.has(id)) {
354
+ await this.threadRows.delete([owner, id]);
355
+ await this.positionRows.delete([owner, id]);
356
+ await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
357
+ }
358
+ // A legacy document can replace the full profile after normalization. Its
359
+ // replacement is authoritative for the row set; discard unreachable bodies
360
+ // in the same transaction before installing the rebuilt normalized records.
361
+ if (replaceLegacy) {
362
+ await this.messageRows.where("owner").equals(owner).delete();
363
+ await this.threadRows.where("owner").equals(owner).delete();
364
+ await this.positionRows.where("owner").equals(owner).delete();
365
+ }
366
+ // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
367
+ // atomically; bounding total snapshot transaction bytes is separate work.
368
+ for (let offset = 0; offset < positionWrites.length; offset += 128)
369
+ await this.positionRows.bulkPut(positionWrites.slice(offset, offset + 128));
370
+ for (let offset = 0; offset < threadWrites.length; offset += 128)
371
+ await this.threadRows.bulkPut(threadWrites.slice(offset, offset + 128));
372
+ for (let offset = 0; offset < messageWrites.length; offset += 128)
373
+ await this.messageRows.bulkPut(messageWrites.slice(offset, offset + 128));
374
+ for (let offset = 0; offset < messageDeletes.length; offset += 128)
375
+ await this.messageRows.bulkDelete(messageDeletes.slice(offset, offset + 128));
376
+ const header = (state) => ({ ...state, threads: [], normalized: true, threadOrder: state.threads.map(thread => thread.id) });
377
+ const nextHeader = header(next);
378
+ if (!previous || JSON.stringify(header(previous)) !== JSON.stringify(nextHeader))
379
+ await this.profiles.put(nextHeader, this.key(appId, profileId));
380
+ this.normalizedStates.add(next);
381
+ }
208
382
  async load(appId, profileId) {
209
- return this.transaction("r", () => this.profiles.get(this.key(appId, profileId)));
383
+ return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
384
+ }
385
+ async loadVersioned(appId, profileId) {
386
+ return this.transaction(this.migrateLegacy ? "rw" : "r", async () => ({
387
+ state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
388
+ }));
389
+ }
390
+ async loadVersions(appId, profileId) {
391
+ return this.transaction("r", async () => (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
392
+ }
393
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds = []) {
394
+ if (!isValidChatTeamId(teamId) || !Number.isSafeInteger(version) || version < 1)
395
+ throw new Error("INVALID_READ_SCOPE_VERSION");
396
+ return this.transaction("rw", async () => {
397
+ const state = await this.loadCurrent(appId, profileId) ?? createReadIndex(appId, profileId);
398
+ const teams = (await this.versions.get(this.key(appId, profileId)))?.teams ?? {};
399
+ if (readTeamVersion(teams, teamId) >= version)
400
+ return state;
401
+ const next = removeReadTeam(state, teamId, knownThreadIds);
402
+ await this.versions.put({ teams: { ...teams, [teamId]: version } }, this.key(appId, profileId));
403
+ await this.persist(appId, profileId, state, next);
404
+ return next;
405
+ });
210
406
  }
211
407
  async update(appId, profileId, transform) {
212
408
  return this.transaction("rw", async () => {
213
- const previous = await this.profiles.get(this.key(appId, profileId));
214
- const next = transform(previous ?? createReadIndex(appId, profileId));
409
+ const previous = await this.loadCurrent(appId, profileId);
410
+ const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
215
411
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
216
412
  throw new Error("READ_IDENTITY_MISMATCH");
217
- if (JSON.stringify(next) !== JSON.stringify(previous))
218
- await this.profiles.put(next, this.key(appId, profileId));
413
+ await this.persist(appId, profileId, previous, next);
219
414
  return next;
220
415
  });
221
416
  }
@@ -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)
@@ -1,11 +1,14 @@
1
+ import { type ConversationReadMessage } from "./conversationRead.js";
1
2
  import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
2
3
  export type ReadingClientOptions = {
3
4
  appId: string;
4
5
  profileId: string;
5
6
  sessionToken: string;
6
7
  hubHttpUrl: string;
8
+ teamId?: string;
7
9
  persistence?: ReadIndexPersistence;
8
10
  storageScope?: string;
11
+ migrateLegacy?: boolean;
9
12
  fetch?: typeof fetch;
10
13
  onError?: (error: unknown) => void;
11
14
  onRecovered?: () => void;
@@ -15,6 +18,12 @@ export type ReadingClientOptions = {
15
18
  export declare class ReadingClient {
16
19
  private options;
17
20
  private storage;
21
+ private backing;
22
+ private closing?;
23
+ private revoking?;
24
+ private versions?;
25
+ private prepared;
26
+ private accepted;
18
27
  private persistence;
19
28
  private sync;
20
29
  private tasks;
@@ -34,8 +43,17 @@ export declare class ReadingClient {
34
43
  refresh: () => void;
35
44
  synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
36
45
  update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
46
+ private updateScoped;
47
+ prepareTeam(teamId: string, version: number): Promise<void>;
48
+ private assertThreadTeam;
49
+ markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
37
50
  recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
38
51
  recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
39
52
  private record;
53
+ withdraw(): void;
54
+ private stop;
55
+ /** This client is retired permanently. Drain accepted local observations
56
+ * before deleting one team's profile-wide projection; sibling teams survive. */
57
+ revokeTeam(teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<void>;
40
58
  close(): Promise<void>;
41
59
  }