@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,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,6 @@
1
+ import { isOffline } from "./connectivity.js";
2
+ import { IndexedDbResponseCache, isResponseCacheAuthorityError, assertResponseCacheAccess } from "./responseCache.js";
3
+ import { messageScopeKey } from "./messageStore.js";
1
4
  export class MessageReadersError extends Error {
2
5
  status;
3
6
  forbidden;
@@ -12,7 +15,17 @@ export class MessageReadersClient {
12
15
  options;
13
16
  pending = new Map();
14
17
  lifetime = new AbortController();
15
- constructor(options) { this.options = { ...options }; }
18
+ cache;
19
+ revoked = false;
20
+ generation = 0;
21
+ constructor(options) {
22
+ this.options = { ...options };
23
+ if (options.scope) {
24
+ if (options.scope.appId !== options.appId || options.scope.profileId !== options.profileId)
25
+ throw new Error("MESSAGE_READERS_SCOPE_MISMATCH");
26
+ this.cache = new IndexedDbResponseCache(messageScopeKey(options.scope), { access: options.access });
27
+ }
28
+ }
16
29
  getReaders(query, signal) {
17
30
  if (this.lifetime.signal.aborted)
18
31
  return Promise.reject(new Error("MESSAGE_READERS_CLOSED"));
@@ -24,7 +37,7 @@ export class MessageReadersClient {
24
37
  const key = JSON.stringify([input.threadId, input.messageId, input.afterProfileId, input.revision, input.limit]);
25
38
  let task = this.pending.get(key);
26
39
  if (!task) {
27
- task = this.request(input).finally(() => { if (this.pending.get(key) === task)
40
+ task = this.cachedRequest(input, key).finally(() => { if (this.pending.get(key) === task)
28
41
  this.pending.delete(key); });
29
42
  this.pending.set(key, task);
30
43
  }
@@ -37,7 +50,54 @@ export class MessageReadersClient {
37
50
  task.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
38
51
  });
39
52
  }
53
+ async cachedRequest(input, key) {
54
+ // Keep only the standard SDK window. Arbitrary page sizes must not multiply
55
+ // durable cache entries. Revision is part of the key, so changed counts do
56
+ // not reuse a page from a previous membership/read projection.
57
+ const cache = input.limit === 50 ? this.cache : undefined;
58
+ if (!cache)
59
+ return this.request(input);
60
+ const stored = await cache.read(key).catch(error => { if (isResponseCacheAuthorityError(error))
61
+ throw error; this.options.onError?.(error); return undefined; });
62
+ if (this.lifetime.signal.aborted)
63
+ throw new Error("MESSAGE_READERS_CLOSED");
64
+ const generation = this.generation;
65
+ const cached = this.revoked ? undefined : stored?.cached;
66
+ if (cached && (Date.now() - cached.time < 5000 || (typeof navigator !== "undefined" && navigator.onLine === false)))
67
+ return structuredClone(cached.value);
68
+ let value;
69
+ try {
70
+ value = await this.request(input);
71
+ }
72
+ catch (error) {
73
+ if (error instanceof MessageReadersError && error.forbidden) {
74
+ this.revoked = true;
75
+ this.generation++;
76
+ await this.cache?.invalidate().catch(failure => this.options.onError?.(failure));
77
+ throw error;
78
+ }
79
+ if (cached && !this.revoked && generation === this.generation && !this.lifetime.signal.aborted && (error instanceof TypeError || (error instanceof MessageReadersError && error.status >= 500)))
80
+ return structuredClone(cached.value);
81
+ throw error;
82
+ }
83
+ if (generation !== this.generation)
84
+ throw new Error("MESSAGE_READERS_INVALIDATED");
85
+ this.revoked = false;
86
+ if (value.resetRequired)
87
+ await cache?.invalidate().catch(error => this.options.onError?.(error));
88
+ else if (stored)
89
+ await cache?.write(key, value, stored.epoch).catch(error => { if (isResponseCacheAuthorityError(error))
90
+ throw error; this.options.onError?.(error); });
91
+ if (this.lifetime.signal.aborted)
92
+ throw new Error("MESSAGE_READERS_CLOSED");
93
+ return value;
94
+ }
40
95
  async request(input) {
96
+ const scopeVersion = this.options.access ? await assertResponseCacheAccess(this.options.access) : undefined;
97
+ if (this.lifetime.signal.aborted)
98
+ throw new Error("MESSAGE_READERS_CLOSED");
99
+ if (isOffline())
100
+ throw new MessageReadersError(0, "MESSAGE_READERS_OFFLINE_UNAVAILABLE");
41
101
  const { token, fetch: fetcher, hubHttpUrl, appId, profileId } = this.options;
42
102
  const controller = new AbortController();
43
103
  const cancel = () => controller.abort(this.lifetime.signal.reason);
@@ -60,6 +120,8 @@ export class MessageReadersClient {
60
120
  throw new Error("INVALID_MESSAGE_READERS_RESPONSE");
61
121
  if (this.lifetime.signal.aborted)
62
122
  throw new Error("MESSAGE_READERS_CLOSED");
123
+ if (this.options.access)
124
+ await assertResponseCacheAccess(this.options.access, scopeVersion);
63
125
  return data;
64
126
  }
65
127
  finally {
@@ -67,5 +129,7 @@ export class MessageReadersClient {
67
129
  this.lifetime.signal.removeEventListener("abort", cancel);
68
130
  }
69
131
  }
70
- close() { this.lifetime.abort(); this.pending.clear(); }
132
+ withdraw() { this.lifetime.abort(); this.pending.clear(); }
133
+ retireScope(version) { return this.cache?.retire(version) ?? Promise.resolve(); }
134
+ close() { this.lifetime.abort(); this.pending.clear(); return this.cache?.close().catch(error => this.options.onError?.(error)) ?? Promise.resolve(); }
71
135
  }
@@ -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
  });
@@ -0,0 +1,47 @@
1
+ import type { StoredMessage } from "./messageStore.js";
2
+ import type { OutgoingMessage } from "./durableSender.js";
3
+ import type { ChatRuntimeMessage } from "./protocol.js";
4
+ import type { MessageReadingState } from "./conversationReading.js";
5
+ export type TimelineReactionEvent = Reaction & {
6
+ threadId: string;
7
+ };
8
+ export type TimelineReaction = {
9
+ emojiId: string;
10
+ userIds: string[];
11
+ mine: boolean;
12
+ };
13
+ export type TimelineItem<T> = {
14
+ id: string;
15
+ threadId: string;
16
+ timestamp: number;
17
+ from?: string;
18
+ mine: boolean;
19
+ status: "sending" | "sent" | "failed" | "read";
20
+ recalled: boolean;
21
+ message: T | undefined;
22
+ reading?: MessageReadingState;
23
+ reactions: readonly TimelineReaction[];
24
+ reactionEvents: readonly TimelineReactionEvent[];
25
+ };
26
+ type Reaction = {
27
+ id: string;
28
+ messageId: string;
29
+ from: string;
30
+ emojiId: string;
31
+ active: boolean;
32
+ revision: number;
33
+ timestamp: number;
34
+ };
35
+ /** Derives one display stream without copying bodies or creating another cache.
36
+ * Outgoing rows retain their wire identity; indexed rows take precedence. */
37
+ export declare function projectMessageTimeline<T extends StoredMessage>(input: {
38
+ appId: string;
39
+ profileId: string;
40
+ threadId: string;
41
+ messages: readonly T[];
42
+ outgoing: readonly OutgoingMessage[];
43
+ decode: (message: ChatRuntimeMessage) => T | undefined;
44
+ reading: Readonly<Record<string, MessageReadingState>>;
45
+ previous?: readonly TimelineItem<T>[];
46
+ }): readonly TimelineItem<T>[];
47
+ export {};
@@ -0,0 +1,113 @@
1
+ import { isReadingControl } from "./readingContent.js";
2
+ function content(message, appId) {
3
+ const body = message.body;
4
+ return body?.kind === "message" && body.content?.appId === appId ? body : undefined;
5
+ }
6
+ function validReaction(value) {
7
+ if (!value || typeof value !== "object")
8
+ return false;
9
+ const event = value;
10
+ return [event.id, event.messageId, event.from, event.emojiId].every(field => typeof field === "string" && field.length > 0 && field.length <= 256) && typeof event.active === "boolean" && Number.isSafeInteger(event.revision) && event.revision >= 0 && Number.isFinite(event.timestamp);
11
+ }
12
+ /** Derives one display stream without copying bodies or creating another cache.
13
+ * Outgoing rows retain their wire identity; indexed rows take precedence. */
14
+ export function projectMessageTimeline(input) {
15
+ const scoped = (message) => {
16
+ const value = message, body = value.body;
17
+ const app = body?.content?.appId ?? body?.payload?.appId;
18
+ return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
19
+ };
20
+ const records = new Map();
21
+ for (const pending of input.outgoing) {
22
+ if (pending.threadId !== input.threadId || pending.confirmed)
23
+ continue;
24
+ const message = input.decode(pending.message);
25
+ if (message && scoped(message))
26
+ records.set(message.id, { message, status: pending.status });
27
+ }
28
+ for (const message of input.messages)
29
+ if (scoped(message)) {
30
+ const legacy = message.legacyDisplay;
31
+ if (legacy && records.has(message.id))
32
+ continue;
33
+ const legacyStatus = legacy?.status;
34
+ const status = legacyStatus === "failed" || legacyStatus === "sending" || legacyStatus === "read" ? legacyStatus : "sent";
35
+ records.set(message.id, { message, status });
36
+ }
37
+ const recalled = new Map(), reactions = new Map();
38
+ const addReaction = (event) => {
39
+ const key = JSON.stringify([event.messageId, event.from, event.emojiId]), old = reactions.get(key);
40
+ if (!old || event.revision > old.revision || event.revision === old.revision && (event.timestamp > old.timestamp || event.timestamp === old.timestamp && event.id > old.id))
41
+ reactions.set(key, event);
42
+ };
43
+ for (const { message, status } of records.values()) {
44
+ if (status === "failed" && isReadingControl(message.body, input.appId))
45
+ continue;
46
+ const envelope = message, body = content(envelope, input.appId), target = body?.content?.recall?.messageId;
47
+ if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
48
+ if (!recalled.has(target))
49
+ recalled.set(target, new Set());
50
+ recalled.get(target).add(envelope.from);
51
+ }
52
+ const reaction = body?.content?.reaction;
53
+ if (body?.contentKind === "text" && reaction?.version === 1 && typeof reaction.messageId === "string" && typeof reaction.emojiId === "string" && typeof reaction.active === "boolean" && typeof reaction.revision === "number" && reaction.revision >= 1 && envelope.from) {
54
+ const event = { id: message.id, messageId: reaction.messageId, from: envelope.from, emojiId: reaction.emojiId, active: reaction.active, revision: reaction.revision, timestamp: message.timestamp };
55
+ if (validReaction(event))
56
+ addReaction(event);
57
+ }
58
+ const events = envelope.reactionEvents ?? envelope.legacyDisplay?.reactionEvents;
59
+ if (Array.isArray(events))
60
+ for (const event of events)
61
+ if (validReaction(event) && event.messageId === message.id && (event.threadId === undefined || event.threadId === input.threadId))
62
+ addReaction(event);
63
+ // Old display-only imports may have aggregates but no event IDs. Treat them
64
+ // as revision-zero baselines; any durable control supersedes them.
65
+ const baseline = envelope.legacyDisplay?.reactions;
66
+ if (Array.isArray(baseline))
67
+ for (const value of baseline) {
68
+ if (typeof value?.emojiId !== "string" || !Array.isArray(value.userIds))
69
+ continue;
70
+ for (const from of value.userIds) {
71
+ const event = { id: `legacy:${message.id}`, messageId: message.id, from, emojiId: value.emojiId, active: true, revision: 0, timestamp: 0 };
72
+ if (validReaction(event))
73
+ addReaction(event);
74
+ }
75
+ }
76
+ }
77
+ const groupedEvents = new Map();
78
+ for (const event of reactions.values()) {
79
+ const values = groupedEvents.get(event.messageId) ?? [];
80
+ values.push({ ...event, threadId: input.threadId });
81
+ groupedEvents.set(event.messageId, values);
82
+ }
83
+ for (const values of groupedEvents.values())
84
+ values.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : a.emojiId < b.emojiId ? -1 : a.emojiId > b.emojiId ? 1 : 0);
85
+ const grouped = new Map();
86
+ for (const event of reactions.values())
87
+ if (event.active) {
88
+ if (!grouped.has(event.messageId))
89
+ grouped.set(event.messageId, new Map());
90
+ const emojis = grouped.get(event.messageId);
91
+ if (!emojis.has(event.emojiId))
92
+ emojis.set(event.emojiId, new Set());
93
+ emojis.get(event.emojiId).add(event.from);
94
+ }
95
+ const previous = new Map(input.previous?.map(item => [item.id, item]) ?? []);
96
+ const result = [];
97
+ for (const { message, status } of records.values()) {
98
+ const envelope = message;
99
+ if (isReadingControl(envelope.body, input.appId))
100
+ continue;
101
+ const mine = envelope.from === input.profileId, reading = input.reading[message.id];
102
+ const withdrawn = Boolean(message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
103
+ const item = { id: message.id, threadId: message.threadId, timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading ? (reading.read ? "read" : status === "read" ? "sent" : status) : status, recalled: withdrawn,
104
+ message: withdrawn ? undefined : message, reading, reactionEvents: withdrawn ? [] : groupedEvents.get(message.id) ?? [], reactions: withdrawn ? [] : [...(grouped.get(message.id) ?? [])].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([emojiId, users]) => { const userIds = [...users].sort(); return { emojiId, userIds, mine: userIds.includes(input.profileId) }; }) };
105
+ const old = previous.get(item.id);
106
+ const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
107
+ const sameReactions = old?.reactions.length === item.reactions.length && old.reactions.every((reaction, index) => { const next = item.reactions[index]; return reaction.emojiId === next.emojiId && reaction.mine === next.mine && reaction.userIds.length === next.userIds.length && reaction.userIds.every((user, i) => user === next.userIds[i]); });
108
+ const sameEvents = old?.reactionEvents.length === item.reactionEvents.length && old.reactionEvents.every((event, index) => { const next = item.reactionEvents[index]; return event.id === next.id && event.from === next.from && event.emojiId === next.emojiId && event.active === next.active && event.revision === next.revision && event.timestamp === next.timestamp && event.threadId === next.threadId && event.messageId === next.messageId; });
109
+ result.push(old && sameEvents && old.message === item.message && old.timestamp === item.timestamp && old.from === item.from && old.mine === item.mine && old.status === item.status && old.recalled === item.recalled && sameReading && sameReactions ? old : item);
110
+ }
111
+ result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
112
+ return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
113
+ }
@@ -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
  }