@pmate/chat-sdk 0.12.0-next.17 → 0.12.0-next.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import type { ConversationReadMessage } from "./conversationRead.js";
1
2
  import { type ReadCountsResult } from "./readCounts.js";
2
3
  import type { OnlineReachEvent } from "./reach.js";
3
4
  import { type MessageReadersQuery } from "./messageReaders.js";
@@ -70,10 +71,14 @@ export declare class ChatSession<T extends StoredMessage> {
70
71
  /** Initializes local state and background reading sync without opening a
71
72
  * second transport when the host already owns calls/presence. */
72
73
  initialize(): Promise<void>;
74
+ /** One-time migration only: never establishes trusted history coverage. */
75
+ importLegacyMessages(threadId: string, messages: T[]): Promise<void>;
73
76
  getReadingSnapshot(): import("./readIndex.js").ReadIndexState | undefined;
74
77
  subscribeReading(listener: () => void): () => void;
75
78
  refreshReading(): void;
76
79
  synchronizeReading(cursor?: string, profileId?: string): Promise<import("./readIndex.js").ReadIndexState>;
80
+ /** Explicit text-history acknowledgement; unheard audio stays unread. */
81
+ markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<import("./readIndex.js").ReadIndexState>;
77
82
  updateReading(transform: Parameters<ReadingClient["update"]>[0]): Promise<import("./readIndex.js").ReadIndexState>;
78
83
  private assertOpen;
79
84
  getMessages(threadId: string, options?: {
@@ -117,10 +117,14 @@ export class ChatSession {
117
117
  this.assertOpen();
118
118
  })().catch(error => { this.initializing = undefined; throw error; });
119
119
  }
120
+ /** One-time migration only: never establishes trusted history coverage. */
121
+ importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
120
122
  getReadingSnapshot() { return this.reading.getSnapshot(); }
121
123
  subscribeReading(listener) { this.assertOpen(); return this.reading.subscribe(listener); }
122
124
  refreshReading() { this.assertOpen(); this.reading.refresh(); }
123
125
  synchronizeReading(cursor, profileId) { this.assertOpen(); return this.reading.synchronize(cursor, profileId); }
126
+ /** Explicit text-history acknowledgement; unheard audio stays unread. */
127
+ markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages); }
124
128
  updateReading(transform) { this.assertOpen(); return this.reading.update(transform); }
125
129
  assertOpen() { if (this.closed)
126
130
  throw new Error("CHAT_SESSION_CLOSED"); }
@@ -194,6 +198,8 @@ export class ChatSession {
194
198
  break;
195
199
  }
196
200
  pages.push(page.messages);
201
+ if (!page.complete)
202
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
197
203
  }
198
204
  if (restart)
199
205
  continue;
@@ -0,0 +1,13 @@
1
+ import { type ReadIndexState } from "./readIndex.js";
2
+ /** Optional observations whose index metadata may still be in flight. */
3
+ export type ConversationReadMessage = {
4
+ id: string;
5
+ threadId: string;
6
+ mine?: boolean;
7
+ from?: string;
8
+ recalled?: boolean;
9
+ readPolicy?: "visible" | "explicit";
10
+ kind?: string;
11
+ };
12
+ /** Explicitly mark ordinary text history read; unheard audio stays unread. */
13
+ export declare function recordConversationRead(state: ReadIndexState, conversationId: string, messages?: readonly ConversationReadMessage[], operationId?: () => `${string}-${string}-${string}-${string}-${string}`): ReadIndexState;
@@ -0,0 +1,34 @@
1
+ import { recordReadIntent, sourcesInConversation } from "./readIndex.js";
2
+ /** Explicitly mark ordinary text history read; unheard audio stays unread. */
3
+ export function recordConversationRead(state, conversationId, messages = [], operationId = () => crypto.randomUUID()) {
4
+ if (!conversationId)
5
+ throw new Error("INVALID_READING_THREAD");
6
+ const sources = sourcesInConversation(state, conversationId);
7
+ if (new Set(sources.map(source => source.teamId)).size > 1)
8
+ throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
9
+ let next = state;
10
+ const indexedIds = new Set();
11
+ for (const source of sources) {
12
+ const incoming = source.messages.filter(message => {
13
+ indexedIds.add(message.id);
14
+ return !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
15
+ });
16
+ const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > source.readThroughSeq)
17
+ .reduce((last, message) => !last || message.seq > last.seq ? message : last, undefined);
18
+ if (latest)
19
+ next = recordReadIntent(next, { operationId: operationId(), threadId: source.id, messageId: latest.id, readPolicy: "visible" });
20
+ }
21
+ // Live/history messages can precede their read-index metadata. Persist their
22
+ // observations so leaving the page or reloading cannot resurrect the badge.
23
+ const observed = new Set(next.observedReadIntents?.filter(intent => intent.threadId === conversationId).map(intent => intent.messageId));
24
+ for (const message of messages) {
25
+ if (message.threadId !== conversationId || message.mine || message.from === state.profileId || message.recalled || indexedIds.has(message.id) || observed.has(message.id))
26
+ continue;
27
+ const readPolicy = message.readPolicy ?? (message.kind === "voice" ? "explicit" : "visible");
28
+ if (message.kind === "voice" || readPolicy === "explicit")
29
+ continue;
30
+ next = recordReadIntent(next, { operationId: operationId(), threadId: conversationId, messageId: message.id, readPolicy });
31
+ observed.add(message.id);
32
+ }
33
+ return next;
34
+ }
@@ -29,3 +29,4 @@ export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
+ export * from "./conversationRead.js";
@@ -29,3 +29,4 @@ export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
+ export * from "./conversationRead.js";
@@ -54,6 +54,7 @@ export declare class MessageClient<T extends StoredMessage> {
54
54
  private queueBytes;
55
55
  private pendingBytes;
56
56
  private pendingRecords;
57
+ private legacyImports;
57
58
  constructor(options: MessageClientOptions<T>);
58
59
  getSnapshot(thread: string): MessageSnapshot<T> | undefined;
59
60
  subscribe(thread: string, listener: () => void): () => void;
@@ -63,6 +64,9 @@ export declare class MessageClient<T extends StoredMessage> {
63
64
  read(thread: string, limit?: number, refresh?: boolean): Promise<MessageSnapshot<T>>;
64
65
  /** Application connection/wake events invalidate, rather than fetching directly. */
65
66
  invalidate(thread: string): void;
67
+ private fallback;
68
+ /** Migration batches remain separate from authoritative message coverage. */
69
+ importLegacy(thread: string, messages: T[]): Promise<void>;
66
70
  private reload;
67
71
  /** Events are visible only after their atomic batch commits. */
68
72
  receive(thread: string, messages: T[]): Promise<void>;
@@ -75,12 +79,7 @@ export declare class MessageClient<T extends StoredMessage> {
75
79
  earlier(thread: string, before: {
76
80
  timestamp: number;
77
81
  id: string;
78
- }, limit?: number): Promise<{
79
- messages: T[];
80
- hasEarlier: boolean;
81
- resetVersion: number;
82
- complete: boolean;
83
- }>;
82
+ }, limit?: number): Promise<MessageSnapshot<T>>;
84
83
  private fillWindow;
85
84
  private denyView;
86
85
  revoke(thread: string): Promise<void>;
@@ -23,6 +23,7 @@ export class MessageClient {
23
23
  queueBytes;
24
24
  pendingBytes = 0;
25
25
  pendingRecords = 0;
26
+ legacyImports = 0;
26
27
  constructor(options) {
27
28
  this.options = options;
28
29
  this.storage = options.storage ?? new IndexedDbMessageStore(options.scope, options.merge ?? mergeStoredMessage);
@@ -53,6 +54,11 @@ export class MessageClient {
53
54
  options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
54
55
  })();
55
56
  }
57
+ else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
58
+ const thread = event.data.thread;
59
+ void this.storage.read(thread, 1).then(page => this.reload(thread, !page.coverage?.complete)).catch(error => { if (!this.closed)
60
+ options.onError?.(error, thread); });
61
+ }
56
62
  else if (typeof event.data === "string" && this.watches.has(event.data))
57
63
  void this.reload(event.data).catch(error => { if (!this.closed)
58
64
  options.onError?.(error, event.data); });
@@ -98,8 +104,11 @@ export class MessageClient {
98
104
  const local = await this.storage.read(thread, limit);
99
105
  if (refresh || !local.coverage?.complete || local.coverage.revoked || this.revoked.has(thread)) {
100
106
  if (typeof navigator !== "undefined" && navigator.onLine === false) {
101
- if (!refresh && local.messages.length && !local.coverage?.revoked)
102
- return this.reload(thread);
107
+ if (!refresh && !local.coverage?.revoked) {
108
+ const fallback = await this.fallback(thread, limit);
109
+ if (fallback.messages.length)
110
+ return fallback;
111
+ }
103
112
  throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
104
113
  }
105
114
  this.revoked.delete(thread);
@@ -107,10 +116,13 @@ export class MessageClient {
107
116
  await this.synchronize(thread);
108
117
  }
109
118
  catch (error) {
110
- if (refresh || !local.messages.length || [401, 403].includes(error?.status ?? 0))
119
+ if (refresh || [401, 403].includes(error?.status ?? 0))
120
+ throw error;
121
+ const fallback = await this.fallback(thread, limit);
122
+ if (!fallback.messages.length)
111
123
  throw error;
112
124
  this.options.onError?.(error, thread);
113
- return this.reload(thread);
125
+ return fallback;
114
126
  }
115
127
  }
116
128
  if (this.closed)
@@ -122,12 +134,11 @@ export class MessageClient {
122
134
  catch (error) {
123
135
  if ([401, 403].includes(error?.status ?? 0))
124
136
  throw error;
125
- const cached = await this.storage.read(thread, limit);
137
+ const cached = await this.fallback(thread, limit);
126
138
  if (!cached.messages.length)
127
139
  throw error;
128
140
  this.options.onError?.(error, thread);
129
- await this.reload(thread);
130
- return { messages: cached.messages, hasEarlier: true, complete: false, resetVersion: cached.coverage?.resetVersion ?? 0 };
141
+ return cached;
131
142
  }
132
143
  const snapshot = await this.reload(thread);
133
144
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
@@ -144,9 +155,45 @@ export class MessageClient {
144
155
  if (!this.closed && !this.closing && !this.revoked.has(thread))
145
156
  this.syncTasks.schedule(thread, () => this.synchronize(thread));
146
157
  }
147
- async reload(thread) {
158
+ async fallback(thread, limit, before) {
159
+ const page = await this.storage.readFallback(thread, limit, before);
160
+ if (!before && page.messages.length)
161
+ await this.reload(thread, true);
162
+ return { messages: page.messages, hasEarlier: true, complete: false, resetVersion: page.coverage?.resetVersion ?? 0 };
163
+ }
164
+ /** Migration batches remain separate from authoritative message coverage. */
165
+ importLegacy(thread, messages) {
166
+ if (this.closed || this.closing)
167
+ return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
168
+ if (messages.length > 128)
169
+ return Promise.reject(new Error("MESSAGE_BATCH_TOO_LARGE"));
170
+ if (this.legacyImports >= 4)
171
+ return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
172
+ let records;
173
+ try {
174
+ let bytes = 0;
175
+ for (const message of messages) {
176
+ bytes += this.messageBytes(message);
177
+ if (bytes > Math.min(this.transactionBytes, 1_048_576))
178
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
179
+ }
180
+ records = structuredClone(messages);
181
+ }
182
+ catch (error) {
183
+ return Promise.reject(error);
184
+ }
185
+ this.legacyImports++;
186
+ const task = this.storage.importLegacy(thread, records).then(async () => {
187
+ const page = await this.storage.read(thread, 1);
188
+ await this.reload(thread, !page.coverage?.complete);
189
+ this.channel?.postMessage({ type: "legacy", thread });
190
+ }).finally(() => { this.legacyImports--; this.activeWrites.delete(task); });
191
+ this.activeWrites.add(task);
192
+ return task;
193
+ }
194
+ async reload(thread, fallback = false) {
148
195
  const limit = 100;
149
- const page = await this.storage.read(thread, limit);
196
+ const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
150
197
  if (this.closed)
151
198
  throw new Error("MESSAGE_CLIENT_CLOSED");
152
199
  if (this.revoked.has(thread)) {
@@ -160,7 +207,7 @@ export class MessageClient {
160
207
  return old && JSON.stringify(old) === JSON.stringify(message) ? old : message;
161
208
  });
162
209
  const old = watch?.snapshot;
163
- const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !this.revoked.has(thread) && (page.coverage?.complete ?? false);
210
+ const resetVersion = Math.max(page.coverage?.resetVersion ?? 0, this.revoked.has(thread) ? watch?.snapshot.resetVersion ?? 0 : 0), complete = !fallback && !this.revoked.has(thread) && (page.coverage?.complete ?? false);
164
211
  const hasEarlier = !this.revoked.has(thread) && (page.hasEarlier || Boolean(page.coverage?.nextBefore));
165
212
  if (old && old.resetVersion === resetVersion && old.complete === complete && old.hasEarlier === hasEarlier && old.messages.length === messages.length && old.messages.every((message, index) => message === messages[index]))
166
213
  return old;
@@ -340,9 +387,23 @@ export class MessageClient {
340
387
  throw new Error("MESSAGE_CLIENT_CLOSED");
341
388
  if (this.revoked.has(thread))
342
389
  throw new Error("MESSAGE_ACCESS_REVOKED");
343
- if (!(await this.storage.read(thread, 1)).coverage?.complete)
344
- await this.synchronize(thread);
345
- return this.fillWindow(thread, limit, before);
390
+ try {
391
+ if (!(await this.storage.read(thread, 1)).coverage?.complete) {
392
+ if (typeof navigator !== "undefined" && navigator.onLine === false)
393
+ throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
394
+ await this.synchronize(thread);
395
+ }
396
+ return await this.fillWindow(thread, limit, before);
397
+ }
398
+ catch (error) {
399
+ if ([401, 403].includes(error?.status ?? 0))
400
+ throw error;
401
+ const fallback = await this.fallback(thread, limit, before);
402
+ if (!fallback.messages.length)
403
+ throw error;
404
+ this.options.onError?.(error, thread);
405
+ return fallback;
406
+ }
346
407
  }
347
408
  async fillWindow(thread, limit, before) {
348
409
  let page = await this.storage.read(thread, limit, before);
@@ -30,6 +30,7 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
30
30
  private merge;
31
31
  private db;
32
32
  private rows;
33
+ private legacy;
33
34
  private coverage;
34
35
  private key;
35
36
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
@@ -45,6 +46,17 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
45
46
  hasEarlier: boolean;
46
47
  coverage: CoverageRow | undefined;
47
48
  }>;
49
+ /** Migration records never claim coverage or overwrite authoritative rows. */
50
+ importLegacy(thread: string, messages: T[]): Promise<number>;
51
+ /** Bounded fallback only; callers must report an incomplete window. */
52
+ readFallback(thread: string, limit?: number, before?: {
53
+ timestamp: number;
54
+ id: string;
55
+ }): Promise<{
56
+ messages: T[];
57
+ hasEarlier: boolean;
58
+ coverage: CoverageRow | undefined;
59
+ }>;
48
60
  commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
49
61
  version: number;
50
62
  authoritative?: boolean;
@@ -6,6 +6,7 @@ export class IndexedDbMessageStore {
6
6
  merge;
7
7
  db;
8
8
  rows;
9
+ legacy;
9
10
  coverage;
10
11
  key;
11
12
  constructor(scope, merge, options = {}) {
@@ -13,6 +14,8 @@ export class IndexedDbMessageStore {
13
14
  this.key = messageScopeKey(scope);
14
15
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
15
16
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
17
+ this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
18
+ this.legacy = this.db.table("legacyMessages");
16
19
  this.rows = this.db.table("messages");
17
20
  this.coverage = this.db.table("coverage");
18
21
  this.db.on("versionchange", () => { this.db.close({ disableAutoOpen: false }); return false; });
@@ -27,32 +30,87 @@ export class IndexedDbMessageStore {
27
30
  return { messages: records.slice(0, limit).reverse().map(row => row.value), hasEarlier: records.length > limit, coverage: await this.coverage.get([this.key, thread]) };
28
31
  });
29
32
  }
33
+ /** Migration records never claim coverage or overwrite authoritative rows. */
34
+ async importLegacy(thread, messages) {
35
+ if (messages.length > 128)
36
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
37
+ let bytes = 0;
38
+ for (const message of messages) {
39
+ if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
40
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
41
+ bytes += new TextEncoder().encode(JSON.stringify(message)).byteLength;
42
+ if (bytes > 1_048_576)
43
+ throw new Error("MESSAGE_BATCH_TOO_LARGE");
44
+ }
45
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
46
+ const coverage = await this.coverage.get([this.key, thread]);
47
+ if (coverage?.revoked)
48
+ throw new Error("MESSAGE_ACCESS_REVOKED");
49
+ if ((coverage?.resetVersion ?? 0) > 0)
50
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
51
+ const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
52
+ const authoritative = await this.rows.bulkGet(keys), previous = await this.legacy.bulkGet(keys);
53
+ const known = new Set(authoritative.flatMap(row => row ? [row.id] : []));
54
+ const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : [])), changed = new Map();
55
+ for (const message of messages) {
56
+ if (known.has(message.id))
57
+ continue;
58
+ const old = latest.get(message.id), value = this.merge(old, message);
59
+ if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
60
+ throw new Error("MESSAGE_SCOPE_MISMATCH");
61
+ latest.set(message.id, value);
62
+ if (JSON.stringify(old) !== JSON.stringify(value))
63
+ changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
64
+ }
65
+ if (changed.size)
66
+ await this.legacy.bulkPut([...changed.values()]);
67
+ return changed.size;
68
+ });
69
+ }
70
+ /** Bounded fallback only; callers must report an incomplete window. */
71
+ async readFallback(thread, limit = 100, before) {
72
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
73
+ 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]);
76
+ if (coverage?.revoked)
77
+ return { messages: [], hasEarlier: false, coverage };
78
+ 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();
79
+ const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
80
+ 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));
81
+ return { messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage };
82
+ });
83
+ }
30
84
  async commit(thread, messages, coverage, guard) {
31
85
  for (const message of messages)
32
86
  if (message.threadId !== thread || !message.id || !Number.isFinite(message.timestamp))
33
87
  throw new Error("MESSAGE_SCOPE_MISMATCH");
34
88
  if (messages.length > 1000)
35
89
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
36
- return this.db.transaction("rw", this.rows, this.coverage, async () => {
90
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
37
91
  const current = await this.coverage.get([this.key, thread]);
38
92
  if (guard && (current?.resetVersion ?? 0) !== guard.version)
39
93
  throw new Error("MESSAGE_PROJECTION_CHANGED");
40
94
  if (guard && current?.revoked && !guard.authoritative)
41
95
  throw new Error("MESSAGE_ACCESS_REVOKED");
42
96
  const ids = [...new Set(messages.map(message => message.id))];
43
- const previous = await this.rows.bulkGet(ids.map(id => [this.key, thread, id]));
44
- const latest = new Map(previous.flatMap(row => row ? [[row.id, row.value]] : []));
97
+ const keys = ids.map(id => [this.key, thread, id]);
98
+ const previous = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
99
+ const canonicalIds = new Set(previous.flatMap(row => row ? [row.id] : []));
100
+ const latest = new Map([...legacy, ...previous].flatMap(row => row ? [[row.id, row.value]] : []));
45
101
  const changed = new Map();
46
102
  for (const message of messages) {
47
103
  const old = latest.get(message.id), value = this.merge(old, message);
48
104
  if (value.id !== message.id || value.threadId !== thread || !Number.isFinite(value.timestamp))
49
105
  throw new Error("MESSAGE_SCOPE_MISMATCH");
50
106
  latest.set(message.id, value);
51
- if (JSON.stringify(old) !== JSON.stringify(value))
107
+ if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
52
108
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
53
109
  }
54
110
  if (changed.size)
55
111
  await this.rows.bulkPut([...changed.values()]);
112
+ if (ids.length)
113
+ await this.legacy.bulkDelete(ids.map(id => [this.key, thread, id]));
56
114
  if (coverage) {
57
115
  const old = await this.coverage.get([this.key, thread]), next = { ...old, ...coverage, scope: this.key, thread };
58
116
  if (JSON.stringify(old) !== JSON.stringify(next))
@@ -66,22 +124,25 @@ export class IndexedDbMessageStore {
66
124
  }
67
125
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
68
126
  async resetThread(thread, revoked = false) {
69
- return this.db.transaction("rw", this.rows, this.coverage, async () => {
127
+ return this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
70
128
  const old = await this.coverage.get([this.key, thread]);
71
129
  const resetVersion = (old?.resetVersion ?? 0) + 1;
72
130
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
131
+ await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
73
132
  await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
74
133
  return resetVersion;
75
134
  });
76
135
  }
77
136
  async removeThread(thread) {
78
- await this.db.transaction("rw", this.rows, this.coverage, async () => {
137
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
79
138
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
139
+ await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
80
140
  await this.coverage.delete([this.key, thread]);
81
141
  });
82
142
  }
83
143
  async clear() {
84
- await this.db.transaction("rw", this.rows, this.coverage, async () => {
144
+ await this.db.transaction("rw", this.rows, this.legacy, this.coverage, async () => {
145
+ await this.legacy.where("scope").equals(this.key).delete();
85
146
  await this.rows.where("scope").equals(this.key).delete();
86
147
  await this.coverage.where("scope").equals(this.key).delete();
87
148
  });
@@ -1,3 +1,4 @@
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;
@@ -35,6 +36,7 @@ export declare class ReadingClient {
35
36
  refresh: () => void;
36
37
  synchronize(targetCursor?: string, profileId?: string): Promise<ReadIndexState>;
37
38
  update(transform: (state: ReadIndexState) => ReadIndexState): Promise<ReadIndexState>;
39
+ markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
38
40
  recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
39
41
  recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
40
42
  private record;
@@ -1,3 +1,4 @@
1
+ import { recordConversationRead } from "./conversationRead.js";
1
2
  import { BatchedReadIndex } from "./batchedReadIndex.js";
2
3
  import { WindowedTasks } from "./coalescer.js";
3
4
  import { IndexedDbReadIndex, recordReadIntent } from "./readIndex.js";
@@ -95,6 +96,9 @@ export class ReadingClient {
95
96
  }
96
97
  return next;
97
98
  }
99
+ markRead(threadId, messages = []) {
100
+ return this.update(state => recordConversationRead(state, threadId, messages));
101
+ }
98
102
  recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
99
103
  recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
100
104
  record(threadId, ids, readPolicy) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.17",
3
+ "version": "0.12.0-next.19",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",