@pmate/chat-sdk 0.12.0-next.37 → 0.12.0-next.39

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.
@@ -67,7 +67,7 @@ export class ChatSession {
67
67
  return options.connection.onReach(event);
68
68
  throw new Error("REACH_HANDLER_UNAVAILABLE");
69
69
  } });
70
- this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
70
+ this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
71
71
  this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
72
72
  this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess() });
73
73
  this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
@@ -63,7 +63,7 @@ export class MessageClient {
63
63
  const current = await this.storage.scopeAccess();
64
64
  if (!this.closed && (this.scopeVersion < version || current.revoked)) {
65
65
  this.denyScope();
66
- options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), "");
66
+ options.onError?.(accessRevoked(), "");
67
67
  }
68
68
  }).catch(error => options.onError?.(error, ""));
69
69
  }
@@ -76,7 +76,7 @@ export class MessageClient {
76
76
  if (this.closed || (current && !current.revoked && (current.resetVersion ?? 0) >= denied.version))
77
77
  return;
78
78
  this.denyView(denied.thread, denied.version);
79
- options.onError?.(Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }), denied.thread);
79
+ options.onError?.(accessRevoked(), denied.thread);
80
80
  })();
81
81
  }
82
82
  else if (event.data?.type === "legacy" && typeof event.data.thread === "string" && this.watches.has(event.data.thread)) {
@@ -139,7 +139,7 @@ export class MessageClient {
139
139
  const task = (async () => {
140
140
  await this.ensureScope();
141
141
  if (this.scopeDenied)
142
- throw new Error("MESSAGE_ACCESS_REVOKED");
142
+ throw accessRevoked();
143
143
  const local = await this.storage.read(thread, limit);
144
144
  if ((local.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
145
145
  this.denyScope();
@@ -192,6 +192,8 @@ export class MessageClient {
192
192
  return cached;
193
193
  }
194
194
  const snapshot = await this.reload(thread);
195
+ if (this.scopeDenied || this.revoked.has(thread))
196
+ throw accessRevoked();
195
197
  if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
196
198
  && (typeof navigator === "undefined" || navigator.onLine !== false))
197
199
  this.invalidate(thread);
@@ -211,6 +213,8 @@ export class MessageClient {
211
213
  }
212
214
  async fallback(thread, limit, before) {
213
215
  await this.ensureScope();
216
+ if (this.scopeDenied || this.revoked.has(thread))
217
+ throw accessRevoked();
214
218
  const page = await this.storage.readFallback(thread, limit, before);
215
219
  if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
216
220
  this.denyScope();
@@ -310,7 +314,7 @@ export class MessageClient {
310
314
  if (this.closed || this.closing)
311
315
  return Promise.reject(new Error("MESSAGE_CLIENT_UNAVAILABLE"));
312
316
  if (this.revoked.has(thread))
313
- return Promise.reject(new Error("MESSAGE_ACCESS_REVOKED"));
317
+ return Promise.reject(accessRevoked());
314
318
  if (messages.length > 1000 || this.pendingRecords + messages.length > 4096)
315
319
  return Promise.reject(new Error("MESSAGE_STORAGE_BACKPRESSURE"));
316
320
  let bytes = 0;
@@ -331,7 +335,7 @@ export class MessageClient {
331
335
  const committed = new Promise((yes, no) => { resolve = yes; reject = no; });
332
336
  const enqueue = this.ensureScope().then(() => {
333
337
  if (this.closed || this.scopeDenied)
334
- throw new Error("MESSAGE_ACCESS_REVOKED");
338
+ throw accessRevoked();
335
339
  if (!this.scopeAllowed)
336
340
  throw new Error("MESSAGE_SCOPE_NOT_READY");
337
341
  const queue = this.writes.get(thread) ?? [];
@@ -457,7 +461,7 @@ export class MessageClient {
457
461
  }
458
462
  for (;;) {
459
463
  if (this.closed || this.scopeDenied || this.revoked.has(thread))
460
- throw new Error("MESSAGE_ACCESS_REVOKED");
464
+ throw accessRevoked();
461
465
  if (page.resetRequired)
462
466
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
463
467
  await this.persist(thread, page.messages, page.checkpoint, true, after ? undefined : page.nextBefore, Date.now(), version, scopeVersion);
@@ -500,11 +504,11 @@ export class MessageClient {
500
504
  async earlier(thread, before, limit = 100, expand = false) {
501
505
  await this.ensureScope();
502
506
  if (this.scopeDenied)
503
- throw new Error("MESSAGE_ACCESS_REVOKED");
507
+ throw accessRevoked();
504
508
  if (this.closed)
505
509
  throw new Error("MESSAGE_CLIENT_CLOSED");
506
510
  if (this.revoked.has(thread))
507
- throw new Error("MESSAGE_ACCESS_REVOKED");
511
+ throw accessRevoked();
508
512
  try {
509
513
  if (!(await this.storage.read(thread, 1)).coverage?.complete) {
510
514
  if (typeof navigator !== "undefined" && navigator.onLine === false)
@@ -571,6 +575,8 @@ export class MessageClient {
571
575
  await task;
572
576
  page = await this.storage.read(thread, limit, before);
573
577
  }
578
+ if (this.scopeDenied || this.revoked.has(thread))
579
+ throw accessRevoked();
574
580
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
575
581
  }
576
582
  denyView(thread, version) {
@@ -598,7 +604,7 @@ export class MessageClient {
598
604
  for (const thread of this.watches.keys())
599
605
  this.denyView(thread);
600
606
  for (const queue of this.writes.values()) {
601
- queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
607
+ queue.forEach(write => write.reject(accessRevoked()));
602
608
  this.release(queue);
603
609
  }
604
610
  this.writes.clear();
@@ -625,7 +631,7 @@ export class MessageClient {
625
631
  const queue = this.writes.get(thread);
626
632
  this.writes.delete(thread);
627
633
  if (queue) {
628
- queue.forEach(write => write.reject(new Error("MESSAGE_ACCESS_REVOKED")));
634
+ queue.forEach(write => write.reject(accessRevoked()));
629
635
  this.release(queue);
630
636
  }
631
637
  try {
@@ -663,3 +669,4 @@ export class MessageClient {
663
669
  }
664
670
  }
665
671
  }
672
+ function accessRevoked() { return Object.assign(new Error("MESSAGE_ACCESS_REVOKED"), { status: 403 }); }
@@ -7,9 +7,16 @@ export type IndexedReadMessage = {
7
7
  mentions: string[];
8
8
  readPolicy: "visible" | "explicit";
9
9
  recalled: boolean;
10
- /** Cached payload is retained so an offline reload can render the message. */
10
+ /** Legacy standalone readers may retain payloads; unified sessions use the message store. */
11
11
  payload: unknown;
12
+ /** Body-free control classification retained by metadata-only reading indexes. */
13
+ readingControl?: boolean;
12
14
  };
15
+ /** Retain read semantics without a second durable copy of message bodies. */
16
+ export declare function readingMetadata<T extends {
17
+ appId: string;
18
+ threads: IndexedReadThread[];
19
+ }>(state: T): T;
13
20
  export type ReadingOperation = {
14
21
  operationId: string;
15
22
  threadId: string;
@@ -98,6 +105,7 @@ export interface ReadIndexPersistence {
98
105
  export declare class IndexedDbReadIndex implements ReadIndexPersistence {
99
106
  private storageScope?;
100
107
  private migrateLegacy;
108
+ private metadataOnly;
101
109
  private database;
102
110
  private profiles;
103
111
  private threadRows;
@@ -106,11 +114,12 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
106
114
  private normalizedStates;
107
115
  private versions;
108
116
  private closed;
109
- constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean);
117
+ constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
110
118
  private transaction;
111
119
  private key;
112
120
  private hydrate;
113
121
  private loadCurrent;
122
+ private loadStored;
114
123
  /** Message bodies are immutable for local read observations: keep them out of
115
124
  * profile/cursor writes, and update only changed normalized rows on sync. */
116
125
  private persist;
@@ -1,6 +1,24 @@
1
1
  import { Dexie } from "dexie";
2
2
  import { isReadingControl } from "./readingContent.js";
3
3
  import { isValidChatTeamId } from "./thread.js";
4
+ /** Retain read semantics without a second durable copy of message bodies. */
5
+ export function readingMetadata(state) {
6
+ let changed = false;
7
+ const threads = state.threads.map(thread => {
8
+ let different = false;
9
+ const messages = thread.messages.map(message => {
10
+ if (message.payload === null && typeof message.readingControl === "boolean")
11
+ return message;
12
+ different = true;
13
+ return { ...message, payload: null, readingControl: Boolean(message.readingControl) || isReadingControl(message.payload, state.appId) };
14
+ });
15
+ if (!different)
16
+ return thread;
17
+ changed = true;
18
+ return { ...thread, messages };
19
+ });
20
+ return changed ? { ...state, threads } : state;
21
+ }
4
22
  export class ReadSyncGap extends Error {
5
23
  cursor;
6
24
  constructor(cursor) {
@@ -159,7 +177,7 @@ export function unreadInThread(state, threadId, hidden) {
159
177
  if (!thread)
160
178
  throw new Error("UNKNOWN_READ_THREAD");
161
179
  const explicit = new Set(thread.explicitReadMessageIds);
162
- const messages = thread.messages.filter(message => !message.recalled && !hidden?.get(message.id)?.has(message.from) && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
180
+ const messages = thread.messages.filter(message => !message.recalled && !hidden?.get(message.id)?.has(message.from) && !(message.readingControl || isReadingControl(message.payload, state.appId)) && message.from !== state.profileId && message.recipients.includes(state.profileId)
163
181
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
164
182
  const unreadMessageCount = messages.length;
165
183
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
@@ -189,6 +207,7 @@ export function removeReadTeam(state, teamId, knownThreadIds = []) {
189
207
  export class IndexedDbReadIndex {
190
208
  storageScope;
191
209
  migrateLegacy;
210
+ metadataOnly;
192
211
  database;
193
212
  profiles;
194
213
  threadRows;
@@ -197,9 +216,10 @@ export class IndexedDbReadIndex {
197
216
  normalizedStates = new WeakSet();
198
217
  versions;
199
218
  closed = false;
200
- constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false) {
219
+ constructor(factory = globalThis.indexedDB, name = "pmate-social-read-index", storageScope, migrateLegacy = false, metadataOnly = false) {
201
220
  this.storageScope = storageScope;
202
221
  this.migrateLegacy = migrateLegacy;
222
+ this.metadataOnly = metadataOnly;
203
223
  if (!factory)
204
224
  throw new Error("READ_STORAGE_UNAVAILABLE");
205
225
  this.database = new Dexie(name, { indexedDB: factory, IDBKeyRange: globalThis.IDBKeyRange });
@@ -274,13 +294,23 @@ export class IndexedDbReadIndex {
274
294
  return current;
275
295
  }
276
296
  async loadCurrent(appId, profileId) {
297
+ const current = await this.loadStored(appId, profileId);
298
+ if (!current || !this.metadataOnly)
299
+ return current;
300
+ const metadata = readingMetadata(current);
301
+ if (metadata !== current)
302
+ await this.persist(appId, profileId, current, metadata);
303
+ return metadata;
304
+ }
305
+ async loadStored(appId, profileId) {
277
306
  const current = await this.profiles.get(this.key(appId, profileId));
278
307
  if (current?.normalized)
279
308
  return this.hydrate(current, this.key(appId, profileId));
280
309
  if (current) {
281
- if (this.migrateLegacy)
282
- await this.persist(appId, profileId, undefined, current, true);
283
- return current;
310
+ const next = this.metadataOnly ? readingMetadata(current) : current;
311
+ if (this.migrateLegacy || this.metadataOnly)
312
+ await this.persist(appId, profileId, undefined, next, true);
313
+ return next;
284
314
  }
285
315
  if (!this.migrateLegacy || this.storageScope === undefined)
286
316
  return undefined;
@@ -291,7 +321,8 @@ export class IndexedDbReadIndex {
291
321
  const legacyRow = await this.profiles.get([appId, profileId]);
292
322
  if (!legacyRow)
293
323
  return undefined;
294
- const legacy = await this.hydrate(legacyRow, [appId, profileId]);
324
+ const hydrated = await this.hydrate(legacyRow, [appId, profileId]);
325
+ const legacy = this.metadataOnly ? readingMetadata(hydrated) : hydrated;
295
326
  if (legacy.appId !== appId || legacy.profileId !== profileId || legacy.schemaVersion !== 1)
296
327
  throw new Error("READ_IDENTITY_MISMATCH");
297
328
  const ownership = { ...createReadIndex(appId, profileId), legacyOwner: this.storageScope };
@@ -380,10 +411,10 @@ export class IndexedDbReadIndex {
380
411
  this.normalizedStates.add(next);
381
412
  }
382
413
  async load(appId, profileId) {
383
- return this.transaction(this.migrateLegacy ? "rw" : "r", () => this.loadCurrent(appId, profileId));
414
+ return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", () => this.loadCurrent(appId, profileId));
384
415
  }
385
416
  async loadVersioned(appId, profileId) {
386
- return this.transaction(this.migrateLegacy ? "rw" : "r", async () => ({
417
+ return this.transaction(this.migrateLegacy || this.metadataOnly ? "rw" : "r", async () => ({
387
418
  state: await this.loadCurrent(appId, profileId), versions: (await this.versions.get(this.key(appId, profileId)))?.teams ?? {},
388
419
  }));
389
420
  }
@@ -407,7 +438,8 @@ export class IndexedDbReadIndex {
407
438
  async update(appId, profileId, transform) {
408
439
  return this.transaction("rw", async () => {
409
440
  const previous = await this.loadCurrent(appId, profileId);
410
- const next = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
441
+ const transformed = transform(previous ?? createReadIndex(appId, profileId), (await this.versions.get(this.key(appId, profileId)))?.teams ?? {});
442
+ const next = this.metadataOnly ? readingMetadata(transformed) : transformed;
411
443
  if (next.appId !== appId || next.profileId !== profileId || next.schemaVersion !== 1)
412
444
  throw new Error("READ_IDENTITY_MISMATCH");
413
445
  await this.persist(appId, profileId, previous, next);
@@ -4,6 +4,7 @@ export type ReadSyncOptions = {
4
4
  profileId: string;
5
5
  sessionToken: string;
6
6
  hubHttpUrl: string;
7
+ metadataOnly?: boolean;
7
8
  persistence: ReadIndexPersistence;
8
9
  fetch?: typeof globalThis.fetch;
9
10
  onChange?: (state: ReadIndexState) => void;
@@ -1,4 +1,4 @@
1
- import { assertReadIndexVersions, applyReadChange, ReadSyncGap, createReadIndex, installReadSnapshot } from "./readIndex.js";
1
+ import { readingMetadata, 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;
@@ -9,8 +9,11 @@ export class ReadSyncClient {
9
9
  this.options = options;
10
10
  }
11
11
  async loadLocal() {
12
- return await this.options.persistence.load(this.options.appId, this.options.profileId)
13
- ?? createReadIndex(this.options.appId, this.options.profileId);
12
+ const { appId, profileId, persistence, metadataOnly } = this.options;
13
+ const state = await persistence.load(appId, profileId) ?? createReadIndex(appId, profileId);
14
+ if (!metadataOnly || readingMetadata(state) === state)
15
+ return state;
16
+ return persistence.update(appId, profileId, readingMetadata);
14
17
  }
15
18
  stop() { this.controller.abort(); }
16
19
  synchronize() {
@@ -84,7 +87,7 @@ export class ReadSyncClient {
84
87
  throw new Error("READ_SYNC_STOPPED");
85
88
  const next = await persistence.update(appId, profileId, (state, currentVersions) => {
86
89
  assertReadIndexVersions(versions, currentVersions);
87
- const applied = delta.changes.reduce(applyReadChange, state);
90
+ const applied = delta.changes.reduce((current, change) => applyReadChange(current, this.options.metadataOnly ? { ...change, thread: readingMetadata({ appId, threads: [change.thread] }).threads[0] } : change), state);
88
91
  if (applied.syncCursor !== delta.syncCursor)
89
92
  throw new ReadSyncGap(applied.syncCursor);
90
93
  return applied;
@@ -107,7 +110,7 @@ export class ReadSyncClient {
107
110
  // Merge into the latest transaction, including reads made while HTTP was pending.
108
111
  const next = await persistence.update(appId, profileId, (state, currentVersions) => {
109
112
  assertReadIndexVersions(versions, currentVersions);
110
- return installReadSnapshot(state, snapshot);
113
+ return installReadSnapshot(state, this.options.metadataOnly ? readingMetadata(snapshot) : snapshot);
111
114
  });
112
115
  if (!next.pending.length)
113
116
  this.readAfter = 0;
@@ -6,6 +6,7 @@ export type ReadingClientOptions = {
6
6
  sessionToken: string;
7
7
  hubHttpUrl: string;
8
8
  teamId?: string;
9
+ metadataOnly?: boolean;
9
10
  persistence?: ReadIndexPersistence;
10
11
  storageScope?: string;
11
12
  migrateLegacy?: boolean;
@@ -3,7 +3,7 @@ import { isOffline, isOfflineNetworkFailure } from "./connectivity.js";
3
3
  import { recordConversationRead } from "./conversationRead.js";
4
4
  import { BatchedReadIndex } from "./batchedReadIndex.js";
5
5
  import { WindowedTasks } from "./coalescer.js";
6
- import { IndexedDbReadIndex, recordReadIntent, assertReadIndexVersions } from "./readIndex.js";
6
+ import { IndexedDbReadIndex, readingMetadata, recordReadIntent, assertReadIndexVersions } from "./readIndex.js";
7
7
  import { ReadSyncClient } from "./readSync.js";
8
8
  /** Owns durable local reads, network coalescing, retries and state notifications.
9
9
  * Hosts report actual visibility; loading or subscribing never marks messages read. */
@@ -30,7 +30,7 @@ export class ReadingClient {
30
30
  this.options = options;
31
31
  if (options.teamId !== undefined && !isValidChatTeamId(options.teamId))
32
32
  throw new Error("INVALID_READ_TEAM");
33
- const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy));
33
+ const storage = options.persistence ?? (this.storage = new IndexedDbReadIndex(globalThis.indexedDB, "pmate-social-read-index", options.storageScope, options.migrateLegacy, options.metadataOnly));
34
34
  this.backing = storage;
35
35
  this.persistence = new BatchedReadIndex(storage);
36
36
  this.tasks = new WindowedTasks(1000, error => this.failed(error));
@@ -112,7 +112,8 @@ export class ReadingClient {
112
112
  const expected = await versions;
113
113
  const next = await this.persistence.update(this.options.appId, this.options.profileId, (state, currentVersions) => {
114
114
  assertReadIndexVersions(expected, currentVersions, teamId);
115
- return transform(state);
115
+ const next = transform(state);
116
+ return this.options.metadataOnly ? readingMetadata(next) : next;
116
117
  });
117
118
  if (!this.closed) {
118
119
  this.publish(next);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.37",
3
+ "version": "0.12.0-next.39",
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",