@pmate/chat-sdk 0.12.0-next.59 → 0.12.0-next.60

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.
@@ -30,6 +30,9 @@ export class IndexedDbResponseCache {
30
30
  constructor(scope, options = {}) {
31
31
  this.scope = scope;
32
32
  this.options = options;
33
+ for (const [value, max] of [[options.recordBytes ?? 65536, 1048576], [options.entries ?? 256, 256], [options.batchRecords ?? 128, 128]])
34
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
35
+ throw new Error("INVALID_RESPONSE_CACHE_LIMIT");
33
36
  this.db = new Dexie(options.name ?? "pmate-chat-response-windows", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
34
37
  this.db.version(1).stores({ windows: "[scope+key],scope", epochs: "scope" });
35
38
  this.rows = this.db.table("windows");
@@ -72,15 +75,30 @@ export class IndexedDbResponseCache {
72
75
  return { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined };
73
76
  });
74
77
  }
75
- write(key, value, epoch) {
78
+ /** Key-only callers can share one bounded metadata read transaction. */
79
+ async readMany(keys) {
80
+ if (this.closed)
81
+ throw new Error("RESPONSE_CACHE_CLOSED");
82
+ if (keys.length > 128)
83
+ throw new Error("RESPONSE_CACHE_SELECTION_TOO_LARGE");
84
+ await this.synchronizeAccess();
85
+ return this.db.transaction("r", this.rows, this.epochs, async () => {
86
+ const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0, unique = [...new Set(keys)];
87
+ const rows = await this.rows.bulkGet(unique.map(key => [this.scope, key]));
88
+ return new Map(unique.map((key, index) => { const row = rows[index]; return [key, { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined }]; }));
89
+ });
90
+ }
91
+ write(key, value, epoch, expectedRevision) {
76
92
  if (this.closed)
77
93
  return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
94
+ if (expectedRevision !== undefined && !this.options.revision)
95
+ return Promise.reject(new Error("RESPONSE_CACHE_REVISION_UNAVAILABLE"));
78
96
  const copy = structuredClone(value), bytes = new TextEncoder().encode(JSON.stringify(copy)).byteLength;
79
97
  // Count in-flight data as well as queued data; requests cannot create an
80
98
  // unbounded backlog while IndexedDB is blocked by another tab.
81
- if (bytes > 65536 || this.pending.length >= 128 || this.pending.reduce((n, w) => n + w.row.bytes, 0) + bytes > 1048576)
99
+ if (bytes > (this.options.recordBytes ?? 65536) || this.pending.length >= 128 || this.pending.reduce((n, w) => n + w.row.bytes, 0) + bytes > 1048576)
82
100
  return Promise.reject(new Error("RESPONSE_CACHE_BACKPRESSURE"));
83
- const promise = new Promise((resolve, reject) => this.pending.push({ row: { scope: this.scope, key, epoch, time: Date.now(), bytes, value: copy }, resolve, reject }));
101
+ const promise = new Promise((resolve, reject) => this.pending.push({ row: { scope: this.scope, key, epoch, time: Date.now(), bytes, value: copy }, expectedRevision, resolve, reject }));
84
102
  this.tasks.schedule("write", () => this.flush());
85
103
  return promise;
86
104
  }
@@ -122,35 +140,74 @@ export class IndexedDbResponseCache {
122
140
  return this.flush();
123
141
  return;
124
142
  }
125
- const batch = [...this.pending];
126
- if (!batch.length)
143
+ if (!this.pending.length)
127
144
  return;
128
145
  const task = (async () => {
129
- try {
130
- await this.synchronizeAccess();
131
- const accepted = await this.db.transaction("rw", this.rows, this.epochs, async () => {
132
- const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
133
- const valid = batch.filter(write => write.row.epoch === epoch);
134
- const unique = new Map(valid.map(write => [write.row.key, write.row]));
135
- await this.rows.bulkPut([...unique.values()]);
136
- const stored = await this.rows.where("scope").equals(this.scope).toArray();
137
- stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
138
- let bytes = 0;
139
- const evicted = [];
140
- stored.forEach((row, index) => { bytes += row.bytes; if (index >= 256 || bytes > 1048576)
141
- evicted.push([this.scope, row.key]); });
142
- await this.rows.bulkDelete(evicted);
143
- return new Set(valid);
144
- });
145
- batch.forEach(write => accepted.has(write) ? write.resolve() : write.reject(new Error("RESPONSE_CACHE_INVALIDATED")));
146
- }
147
- catch (error) {
148
- batch.forEach(write => write.reject(error));
149
- throw error;
150
- }
151
- finally {
152
- this.pending.splice(0, batch.length);
146
+ let failed = false, failure;
147
+ // Each batch commits and resolves independently. A configured smaller
148
+ // batch still drains all accepted requests, including during close.
149
+ while (this.pending.length) {
150
+ const batch = this.pending.slice(0, this.options.batchRecords ?? 128);
151
+ try {
152
+ await this.synchronizeAccess();
153
+ const outcome = await this.db.transaction("rw", this.rows, this.epochs, async () => {
154
+ const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
155
+ const valid = batch.filter(write => write.row.epoch === epoch);
156
+ const conditional = valid.filter(write => write.expectedRevision !== undefined);
157
+ const keys = [...new Set(conditional.map(write => write.row.key))];
158
+ const current = conditional.length ? await this.rows.bulkGet(keys.map(key => [this.scope, key])) : [];
159
+ const projected = new Map(keys.map((key, index) => [key, current[index]?.epoch === epoch ? current[index] : undefined]));
160
+ const accepted = new Set(), changed = new Set(), unique = new Map();
161
+ for (const write of valid) {
162
+ if (write.expectedRevision !== undefined) {
163
+ const previous = projected.get(write.row.key);
164
+ const revision = previous ? this.options.revision(previous.value) : null;
165
+ if (revision !== write.expectedRevision) {
166
+ // Another document may have already committed this exact
167
+ // revision; it is an idempotent success, never an overwrite.
168
+ if (revision === this.options.revision(write.row.value) && JSON.stringify(previous.value) === JSON.stringify(write.row.value))
169
+ accepted.add(write);
170
+ else
171
+ changed.add(write);
172
+ continue;
173
+ }
174
+ }
175
+ unique.set(write.row.key, write.row);
176
+ projected.set(write.row.key, write.row);
177
+ accepted.add(write);
178
+ }
179
+ if (unique.size) {
180
+ await this.rows.bulkPut([...unique.values()]);
181
+ const stored = await this.rows.where("scope").equals(this.scope).toArray();
182
+ stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
183
+ let bytes = 0;
184
+ const evicted = [];
185
+ stored.forEach((row, index) => { bytes += row.bytes; if (index >= (this.options.entries ?? 256) || bytes > 1048576)
186
+ evicted.push([this.scope, row.key]); });
187
+ if (evicted.length)
188
+ await this.rows.bulkDelete(evicted);
189
+ }
190
+ return { accepted, changed };
191
+ });
192
+ for (const write of batch) {
193
+ if (outcome.accepted.has(write))
194
+ write.resolve();
195
+ else
196
+ write.reject(new Error(outcome.changed.has(write) ? "RESPONSE_CACHE_CHANGED" : "RESPONSE_CACHE_INVALIDATED"));
197
+ }
198
+ }
199
+ catch (error) {
200
+ batch.forEach(write => write.reject(error));
201
+ if (!failed)
202
+ failure = error;
203
+ failed = true;
204
+ }
205
+ finally {
206
+ this.pending.splice(0, batch.length);
207
+ }
153
208
  }
209
+ if (failed)
210
+ throw failure;
154
211
  })().finally(() => { if (this.running === task)
155
212
  this.running = undefined; });
156
213
  this.running = task;
@@ -22,6 +22,7 @@ export type SocialGroup = {
22
22
  title: string;
23
23
  avatar?: string | null;
24
24
  revision: string;
25
+ versionNumber?: number;
25
26
  };
26
27
  export type SocialRelationship = {
27
28
  appId: string;
@@ -62,7 +63,7 @@ export declare class SocialReadClient {
62
63
  group(input: {
63
64
  groupId: string;
64
65
  teamId?: string;
65
- }): Promise<SocialGroup>;
66
+ }, signal?: AbortSignal): Promise<SocialGroup>;
66
67
  relationship(input: {
67
68
  peerId: string;
68
69
  teamId?: string;
@@ -43,9 +43,9 @@ export class SocialReadClient {
43
43
  throw new SocialReadError(502, "INVALID_MESSAGE_LOOKUP_RESPONSE");
44
44
  return value;
45
45
  }
46
- group(input) {
46
+ group(input, signal) {
47
47
  this.requireTeam(input.teamId);
48
- return this.request("group", { groupId: input.groupId, ...this.teamQuery(input.teamId) });
48
+ return this.request("group", { groupId: input.groupId, ...this.teamQuery(input.teamId) }, signal);
49
49
  }
50
50
  relationship(input) {
51
51
  this.requireTeam(input.teamId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.59",
3
+ "version": "0.12.0-next.60",
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",