@pmate/chat-sdk 0.12.0-next.34 → 0.12.0-next.35

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.
@@ -0,0 +1,19 @@
1
+ import type { StoredMessage, MessageScope } from "./messageStore.js";
2
+ export type MessageControl = {
3
+ scope: string;
4
+ thread: string;
5
+ target: string;
6
+ kind: "recall" | "reaction";
7
+ actor: string;
8
+ emoji: string;
9
+ id: string;
10
+ timestamp: number;
11
+ revision: number;
12
+ active: boolean;
13
+ };
14
+ export type ControlKey = [string, string, string, string, string, string];
15
+ export declare const controlKey: (event: MessageControl) => ControlKey;
16
+ export declare function newerControl(old: MessageControl | undefined, next: MessageControl): MessageControl;
17
+ /** Body-free proofs retained independently of the visible history window. */
18
+ export declare function messageControls(message: StoredMessage, scope: MessageScope, key: string): MessageControl[];
19
+ export declare function applyMessageControls<T extends StoredMessage>(messages: T[], controls: readonly MessageControl[]): T[];
@@ -0,0 +1,44 @@
1
+ export const controlKey = (event) => [event.scope, event.thread, event.target, event.kind, event.actor, event.emoji];
2
+ export function newerControl(old, next) {
3
+ return !old || next.revision > old.revision || next.revision === old.revision && (next.timestamp > old.timestamp || next.timestamp === old.timestamp && next.id > old.id) ? next : old;
4
+ }
5
+ const identifier = (value) => typeof value === "string" && value.length > 0 && value.length <= 256;
6
+ /** Body-free proofs retained independently of the visible history window. */
7
+ export function messageControls(message, scope, key) {
8
+ const row = message;
9
+ if (row.appId !== undefined && row.appId !== scope.appId || row.teamId !== undefined && row.teamId !== scope.teamId || !identifier(row.from))
10
+ return [];
11
+ const apps = row.body;
12
+ const app = apps?.content?.appId ?? apps?.payload?.appId;
13
+ if (app !== undefined && app !== scope.appId)
14
+ return [];
15
+ if (row.recalled || row.legacyDisplay?.recalled)
16
+ return [{ scope: key, thread: message.threadId, target: message.id, kind: "recall", actor: row.from, emoji: "", id: message.id, timestamp: message.timestamp, revision: 0, active: true }];
17
+ const body = row.body;
18
+ const content = body?.kind === "message" && body.content?.appId === scope.appId ? body.content : undefined;
19
+ const target = body?.kind === "recall" && row.appId === scope.appId ? body.targetMessageId : content?.recall?.messageId;
20
+ const base = { scope: key, thread: message.threadId, actor: row.from, id: message.id, timestamp: message.timestamp };
21
+ if (identifier(target))
22
+ return [{ ...base, target, kind: "recall", emoji: "", revision: 0, active: true }];
23
+ const reaction = content?.reaction;
24
+ if (body?.contentKind === "text" && reaction?.version === 1 && identifier(reaction.messageId) && identifier(reaction.emojiId) && Number.isSafeInteger(reaction.revision) && (reaction.revision ?? 0) >= 1 && typeof reaction.active === "boolean")
25
+ return [{ ...base, target: reaction.messageId, kind: "reaction", emoji: reaction.emojiId, revision: reaction.revision, active: reaction.active }];
26
+ return [];
27
+ }
28
+ export function applyMessageControls(messages, controls) {
29
+ const grouped = new Map();
30
+ for (const event of controls) {
31
+ const list = grouped.get(event.target) ?? [];
32
+ list.push(event);
33
+ grouped.set(event.target, list);
34
+ }
35
+ return messages.map(message => {
36
+ const facts = grouped.get(message.id);
37
+ if (!facts?.length)
38
+ return message;
39
+ const row = message;
40
+ const recalled = message.recalled || facts.some(event => event.kind === "recall" && event.actor === row.from);
41
+ const reactionEvents = facts.filter(event => event.kind === "reaction").map(event => ({ id: event.id, messageId: event.target, threadId: event.thread, from: event.actor, emojiId: event.emoji, active: event.active, revision: event.revision, timestamp: event.timestamp }));
42
+ return { ...message, ...(recalled ? { recalled: true } : {}), ...(reactionEvents.length ? { reactionEvents: [...(row.reactionEvents ?? []), ...reactionEvents] } : {}) };
43
+ });
44
+ }
@@ -33,12 +33,14 @@ export declare const messageScopeKey: (scope: MessageScope) => string;
33
33
  /** Normalized browser cache. Data and coverage advance together in each commit.
34
34
  * Merge runs inside the write transaction against the latest cross-tab state. */
35
35
  export declare class IndexedDbMessageStore<T extends StoredMessage> {
36
+ private scope;
36
37
  private merge;
37
38
  private db;
38
39
  private rows;
39
40
  private legacy;
40
41
  private coverage;
41
42
  private accessRows;
43
+ private controls;
42
44
  private key;
43
45
  constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
44
46
  indexedDB?: IDBFactory;
@@ -69,6 +71,9 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
69
71
  authoritative?: boolean;
70
72
  scopeVersion?: number;
71
73
  }): Promise<number>;
74
+ private rememberControls;
75
+ private controlPage;
76
+ private finishPage;
72
77
  windows(): Promise<CoverageRow[]>;
73
78
  scopeAccess(): Promise<MessageScopeAccess>;
74
79
  private scopedCoverage;
@@ -1,22 +1,28 @@
1
1
  import { Dexie } from "dexie";
2
+ import { messageControls, controlKey, newerControl, applyMessageControls } from "./messageControls.js";
2
3
  export const messageScopeKey = (scope) => JSON.stringify([scope.appId, scope.accountId, scope.profileId, scope.teamId]);
3
4
  /** Normalized browser cache. Data and coverage advance together in each commit.
4
5
  * Merge runs inside the write transaction against the latest cross-tab state. */
5
6
  export class IndexedDbMessageStore {
7
+ scope;
6
8
  merge;
7
9
  db;
8
10
  rows;
9
11
  legacy;
10
12
  coverage;
11
13
  accessRows;
14
+ controls;
12
15
  key;
13
16
  constructor(scope, merge, options = {}) {
17
+ this.scope = scope;
14
18
  this.merge = merge;
15
19
  this.key = messageScopeKey(scope);
16
20
  this.db = new Dexie(options.name ?? "pmate-chat-messages", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
17
21
  this.db.version(1).stores({ messages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope", coverage: "[scope+thread],scope" });
18
22
  this.db.version(2).stores({ legacyMessages: "[scope+thread+id],[scope+thread+time+id],[scope+thread],scope" });
19
23
  this.db.version(3).stores({ scopeAccess: "scope" });
24
+ this.db.version(4).stores({ messageControls: "[scope+thread+target+kind+actor+emoji],[scope+thread+target],[scope+thread],scope" });
25
+ this.controls = this.db.table("messageControls");
20
26
  this.accessRows = this.db.table("scopeAccess");
21
27
  this.legacy = this.db.table("legacyMessages");
22
28
  this.rows = this.db.table("messages");
@@ -27,14 +33,15 @@ export class IndexedDbMessageStore {
27
33
  async read(thread, limit = 100, before) {
28
34
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
29
35
  throw new Error("INVALID_MESSAGE_LIMIT");
30
- return this.db.transaction("r", this.rows, this.coverage, this.accessRows, async () => {
36
+ const page = await this.db.transaction("r", this.rows, this.coverage, this.accessRows, this.controls, async () => {
31
37
  const access = await this.scopeAccess();
32
38
  if (access.revoked)
33
39
  return { messages: [], hasEarlier: false, coverage: { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } };
34
40
  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);
35
41
  const records = await range.reverse().limit(limit + 1).toArray();
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) };
42
+ return this.controlPage({ 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) });
37
43
  });
44
+ return this.finishPage(thread, page);
38
45
  }
39
46
  /** Migration records never claim coverage or overwrite authoritative rows. */
40
47
  async importLegacy(thread, messages) {
@@ -48,13 +55,19 @@ export class IndexedDbMessageStore {
48
55
  if (bytes > 1_048_576)
49
56
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
50
57
  }
51
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
58
+ return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
52
59
  const access = await this.scopeAccess();
53
60
  const coverage = await this.coverage.get([this.key, thread]);
54
61
  if (access.revoked || coverage?.revoked)
55
62
  throw new Error("MESSAGE_ACCESS_REVOKED");
56
63
  if (access.version > 0)
57
64
  throw new Error("MESSAGE_PROJECTION_CHANGED");
65
+ const proofs = messages.map(message => messageControls(message, this.scope, this.key));
66
+ const controlsOnly = messages.length > 0 && proofs.every((events, index) => events.length > 0 && !messages[index].recalled && !messages[index].legacyDisplay?.recalled);
67
+ if (controlsOnly) {
68
+ await this.rememberControls(proofs.flat());
69
+ return 0;
70
+ }
58
71
  if ((coverage?.resetVersion ?? 0) > 0)
59
72
  throw new Error("MESSAGE_PROJECTION_CHANGED");
60
73
  const ids = [...new Set(messages.map(message => message.id))], keys = ids.map(id => [this.key, thread, id]);
@@ -71,6 +84,7 @@ export class IndexedDbMessageStore {
71
84
  if (JSON.stringify(old) !== JSON.stringify(value))
72
85
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
73
86
  }
87
+ await this.rememberControls([...changed.values()].flatMap(row => messageControls(row.value, this.scope, this.key)));
74
88
  if (changed.size)
75
89
  await this.legacy.bulkPut([...changed.values()]);
76
90
  return changed.size;
@@ -80,7 +94,7 @@ export class IndexedDbMessageStore {
80
94
  async readFallback(thread, limit = 100, before) {
81
95
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
82
96
  throw new Error("INVALID_MESSAGE_LIMIT");
83
- return this.db.transaction("r", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
97
+ const page = await this.db.transaction("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
84
98
  const access = await this.scopeAccess();
85
99
  const coverage = access.revoked ? { complete: false, revoked: true, resetVersion: 0, scopeVersion: access.version } : this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
86
100
  if (coverage?.revoked)
@@ -88,8 +102,9 @@ export class IndexedDbMessageStore {
88
102
  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
103
  const [canonical, legacy] = await Promise.all([read(this.rows), read(this.legacy)]);
90
104
  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 };
105
+ return this.controlPage({ messages: rows.slice(-limit).map(row => row.value), hasEarlier: rows.length > limit, coverage });
92
106
  });
107
+ return this.finishPage(thread, page);
93
108
  }
94
109
  async commit(thread, messages, coverage, guard) {
95
110
  for (const message of messages)
@@ -97,7 +112,7 @@ export class IndexedDbMessageStore {
97
112
  throw new Error("MESSAGE_SCOPE_MISMATCH");
98
113
  if (messages.length > 1000)
99
114
  throw new Error("MESSAGE_BATCH_TOO_LARGE");
100
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
115
+ return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
101
116
  const access = await this.scopeAccess();
102
117
  if ((guard?.scopeVersion ?? 0) !== access.version)
103
118
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -126,6 +141,7 @@ export class IndexedDbMessageStore {
126
141
  if (!canonicalIds.has(message.id) || JSON.stringify(old) !== JSON.stringify(value))
127
142
  changed.set(message.id, { scope: this.key, thread, id: value.id, time: value.timestamp, value });
128
143
  }
144
+ await this.rememberControls(messages.flatMap(message => messageControls(message, this.scope, this.key)));
129
145
  if (changed.size)
130
146
  await this.rows.bulkPut([...changed.values()]);
131
147
  const replaced = legacy.flatMap(row => row ? [[this.key, thread, row.id]] : []);
@@ -139,6 +155,47 @@ export class IndexedDbMessageStore {
139
155
  return changed.size;
140
156
  });
141
157
  }
158
+ async rememberControls(events) {
159
+ const latest = new Map();
160
+ for (const event of events) {
161
+ const key = JSON.stringify(controlKey(event));
162
+ latest.set(key, newerControl(latest.get(key), event));
163
+ }
164
+ const values = [...latest.values()];
165
+ for (let offset = 0; offset < values.length; offset += 128) {
166
+ const batch = values.slice(offset, offset + 128), previous = await this.controls.bulkGet(batch.map(controlKey));
167
+ const changed = batch.filter((event, index) => newerControl(previous[index], event) !== previous[index]);
168
+ if (changed.length)
169
+ await this.controls.bulkPut(changed);
170
+ }
171
+ }
172
+ async controlPage(page) {
173
+ const events = page.messages.flatMap(message => messageControls(message, this.scope, this.key));
174
+ const old = events.length ? await this.controls.bulkGet(events.map(controlKey)) : [];
175
+ const pendingControls = events.filter((event, index) => newerControl(old[index], event) !== old[index]);
176
+ const facts = await this.controls.where("[scope+thread+target]").anyOf(page.messages.map(message => [this.key, message.threadId, message.id])).toArray();
177
+ const latest = new Map();
178
+ for (const event of [...facts, ...events]) {
179
+ const key = JSON.stringify(controlKey(event));
180
+ latest.set(key, newerControl(latest.get(key), event));
181
+ }
182
+ return { ...page, messages: applyMessageControls(page.messages, [...latest.values()]), pendingControls };
183
+ }
184
+ async finishPage(thread, page) {
185
+ const { pendingControls, ...result } = page;
186
+ // v3 caches migrate only source proofs from already-read pages, with no body
187
+ // rewrite. Steady reads remain a single read-only IndexedDB transaction.
188
+ if (pendingControls?.length)
189
+ await this.db.transaction("rw", this.controls, this.coverage, this.accessRows, async () => {
190
+ const access = await this.scopeAccess(), current = await this.coverage.get([this.key, thread]);
191
+ if (access.revoked || access.version !== (page.coverage?.scopeVersion ?? 0))
192
+ throw new Error("MESSAGE_SCOPE_CHANGED");
193
+ if ((current?.resetVersion ?? 0) !== (page.coverage?.resetVersion ?? 0))
194
+ throw new Error("MESSAGE_PROJECTION_CHANGED");
195
+ await this.rememberControls(pendingControls);
196
+ });
197
+ return result;
198
+ }
142
199
  async windows() {
143
200
  return this.db.transaction("r", this.coverage, this.accessRows, async () => {
144
201
  if ((await this.scopeAccess()).revoked)
@@ -156,6 +213,7 @@ export class IndexedDbMessageStore {
156
213
  return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
157
214
  }
158
215
  async clearScopeRows() {
216
+ await this.controls.where("scope").equals(this.key).delete();
159
217
  await this.rows.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
160
218
  await this.legacy.where("[scope+thread+id]").between([this.key, Dexie.minKey, Dexie.minKey], [this.key, Dexie.maxKey, Dexie.maxKey], true, true).delete();
161
219
  await this.coverage.where("[scope+thread]").between([this.key, Dexie.minKey], [this.key, Dexie.maxKey], true, true).delete();
@@ -170,7 +228,7 @@ export class IndexedDbMessageStore {
170
228
  await this.accessRows.put(next);
171
229
  return next;
172
230
  });
173
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
231
+ await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
174
232
  const current = await this.scopeAccess();
175
233
  if (current.version === access.version && current.revoked)
176
234
  await this.clearScopeRows();
@@ -179,7 +237,7 @@ export class IndexedDbMessageStore {
179
237
  }
180
238
  /** A durable epoch prevents another tab's pre-reset request from restoring rows. */
181
239
  async resetThread(thread, revoked = false, scopeVersion = 0) {
182
- return this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
240
+ return this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
183
241
  const access = await this.scopeAccess();
184
242
  if (access.version !== scopeVersion)
185
243
  throw new Error("MESSAGE_SCOPE_CHANGED");
@@ -187,6 +245,8 @@ export class IndexedDbMessageStore {
187
245
  throw new Error("MESSAGE_ACCESS_REVOKED");
188
246
  const old = await this.coverage.get([this.key, thread]);
189
247
  const resetVersion = (old?.resetVersion ?? 0) + 1;
248
+ if (revoked)
249
+ await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
190
250
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
191
251
  await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
192
252
  await this.coverage.put({ scope: this.key, thread, complete: false, resetVersion, revoked });
@@ -194,14 +254,16 @@ export class IndexedDbMessageStore {
194
254
  });
195
255
  }
196
256
  async removeThread(thread) {
197
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
257
+ await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
258
+ await this.controls.where("[scope+thread]").equals([this.key, thread]).delete();
198
259
  await this.rows.where("[scope+thread]").equals([this.key, thread]).delete();
199
260
  await this.legacy.where("[scope+thread]").equals([this.key, thread]).delete();
200
261
  await this.coverage.delete([this.key, thread]);
201
262
  });
202
263
  }
203
264
  async clear() {
204
- await this.db.transaction("rw", this.rows, this.legacy, this.coverage, this.accessRows, async () => {
265
+ await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
266
+ await this.controls.where("scope").equals(this.key).delete();
205
267
  await this.legacy.where("scope").equals(this.key).delete();
206
268
  await this.rows.where("scope").equals(this.key).delete();
207
269
  await this.coverage.where("scope").equals(this.key).delete();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.34",
3
+ "version": "0.12.0-next.35",
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",