@pmate/chat-sdk 0.12.0-next.52 → 0.12.0-next.54
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.
- package/lib/runtime/chatSession.d.ts +1 -0
- package/lib/runtime/chatSession.js +15 -1
- package/lib/runtime/createChat.js +13 -0
- package/lib/runtime/durableSender.d.ts +4 -0
- package/lib/runtime/durableSender.js +99 -6
- package/lib/runtime/messageClient.d.ts +3 -0
- package/lib/runtime/messageClient.js +15 -0
- package/lib/runtime/readIndex.d.ts +3 -0
- package/lib/runtime/readIndex.js +26 -15
- package/lib/runtime/socialReadClient.d.ts +1 -0
- package/lib/runtime/socialReadClient.js +6 -2
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
|
|
|
17
17
|
sharedConnection?: ChatClient;
|
|
18
18
|
activeThreadId?: string;
|
|
19
19
|
uploadVoice?: DurableSenderOptions["uploadVoice"];
|
|
20
|
+
reconcileOutgoing?: DurableSenderOptions["reconcile"];
|
|
20
21
|
reading: ReadingClientOptions;
|
|
21
22
|
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
22
23
|
readingCandidate?: (message: T) => ReadingCandidate | undefined;
|
|
@@ -69,7 +69,21 @@ export class ChatSession {
|
|
|
69
69
|
} });
|
|
70
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
|
-
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice
|
|
72
|
+
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
|
|
73
|
+
const cached = await this.messages.cachedMessage(record.threadId, record.message.hash);
|
|
74
|
+
if (cached && cached.from === scope.profileId && cached.to === record.message.to)
|
|
75
|
+
return true;
|
|
76
|
+
if (!options.reconcileOutgoing)
|
|
77
|
+
throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
|
|
78
|
+
try {
|
|
79
|
+
return await options.reconcileOutgoing(record, signal);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
83
|
+
await this.messages.revoke(record.threadId);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
} });
|
|
73
87
|
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
74
88
|
options.onError(error); });
|
|
75
89
|
this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { voiceUploader } from "./voiceUpload.js";
|
|
2
|
+
import { isOffline } from "./connectivity.js";
|
|
2
3
|
import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
|
|
3
4
|
import { ChatSession } from "./chatSession.js";
|
|
4
5
|
import { SocialReadClient } from "./socialReadClient.js";
|
|
@@ -19,6 +20,18 @@ export function createChat(options) {
|
|
|
19
20
|
return new ChatSession({
|
|
20
21
|
sharedConnection: options.connection,
|
|
21
22
|
uploadVoice: voiceUploader(profileId, options.uploadEndpoint, options.fetch),
|
|
23
|
+
reconcileOutgoing: async (record, signal) => {
|
|
24
|
+
if (isOffline())
|
|
25
|
+
throw new Error("OUTBOX_RECONCILIATION_OFFLINE");
|
|
26
|
+
const result = await reader.messagePage({ threadId: record.threadId, teamId, messageId: record.message.hash }, signal);
|
|
27
|
+
const expected = record.message.body;
|
|
28
|
+
return result.messages.some(message => {
|
|
29
|
+
const body = message.body;
|
|
30
|
+
if (message.from !== profileId || message.to !== record.message.to || body?.kind !== expected.kind || body?.action !== expected.action)
|
|
31
|
+
throw new Error("OUTBOX_RECONCILIATION_MISMATCH");
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
},
|
|
22
35
|
activeThreadId: options.threadId,
|
|
23
36
|
messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
24
37
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
@@ -31,6 +31,7 @@ export type DurableSenderOptions = {
|
|
|
31
31
|
name?: string;
|
|
32
32
|
access?: () => Promise<SenderScopeAccess>;
|
|
33
33
|
uploadVoice?: (blob: Blob, signal: AbortSignal) => Promise<string>;
|
|
34
|
+
reconcile?: (record: Pick<OutgoingMessage, "threadId" | "message">, signal: AbortSignal) => Promise<boolean>;
|
|
34
35
|
};
|
|
35
36
|
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
36
37
|
export declare class DurableChatSender {
|
|
@@ -43,6 +44,8 @@ export declare class DurableChatSender {
|
|
|
43
44
|
private localUrls;
|
|
44
45
|
private localViews;
|
|
45
46
|
private uploads;
|
|
47
|
+
private reconciliations;
|
|
48
|
+
private firstDelivery;
|
|
46
49
|
private uploadTail;
|
|
47
50
|
private uploadWaiting;
|
|
48
51
|
private confirmationQueue;
|
|
@@ -87,6 +90,7 @@ export declare class DurableChatSender {
|
|
|
87
90
|
private persistOutgoing;
|
|
88
91
|
retry(hash: string): Promise<ChatRuntimeMessage>;
|
|
89
92
|
private transmit;
|
|
93
|
+
private reconcile;
|
|
90
94
|
private releaseUrl;
|
|
91
95
|
private releaseUrls;
|
|
92
96
|
private localView;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { blake3 } from "@noble/hashes/blake3.js";
|
|
3
|
+
import { bytesToHex } from "@noble/hashes/utils.js";
|
|
2
4
|
import { withChatTeam } from "./teamScope.js";
|
|
3
5
|
import { messageScopeKey } from "./messageStore.js";
|
|
4
6
|
/** Persists the exact wire message before sending; retries preserve its hash. */
|
|
@@ -12,6 +14,8 @@ export class DurableChatSender {
|
|
|
12
14
|
localUrls = new Map();
|
|
13
15
|
localViews = new WeakMap();
|
|
14
16
|
uploads = new Set();
|
|
17
|
+
reconciliations = new Set();
|
|
18
|
+
firstDelivery = new Set();
|
|
15
19
|
uploadTail = Promise.resolve();
|
|
16
20
|
uploadWaiting = 0;
|
|
17
21
|
confirmationQueue = [];
|
|
@@ -187,6 +191,8 @@ export class DurableChatSender {
|
|
|
187
191
|
this.withdrawn = true;
|
|
188
192
|
this.failConfirmations(new Error("OUTBOX_ACCESS_REVOKED"));
|
|
189
193
|
this.uploads.forEach(controller => controller.abort());
|
|
194
|
+
this.reconciliations.forEach(controller => controller.abort());
|
|
195
|
+
this.firstDelivery.clear();
|
|
190
196
|
this.releaseUrls();
|
|
191
197
|
this.values.clear();
|
|
192
198
|
this.emit();
|
|
@@ -249,17 +255,43 @@ export class DurableChatSender {
|
|
|
249
255
|
const message = this.client.prepareChat(input.to, withChatTeam(input.body, this.identity.teamId), input.recipients);
|
|
250
256
|
if (message.from !== this.identity.profileId)
|
|
251
257
|
throw new Error("OUTGOING_MESSAGE_IDENTITY_MISMATCH");
|
|
258
|
+
const command = statefulGroup(message);
|
|
259
|
+
if (command)
|
|
260
|
+
message.hash = commandId(this.scope, this.version, threadId, message);
|
|
252
261
|
if (input.voiceBlob) {
|
|
253
262
|
const body = message.body;
|
|
254
263
|
if (!(input.voiceBlob instanceof Blob) || input.voiceBlob.size === 0 || input.voiceBlob.size > 16 * 1024 * 1024 || body.kind !== "message" || !validVoice(body.content.voice))
|
|
255
264
|
throw new Error("INVALID_VOICE_ATTACHMENT");
|
|
256
265
|
}
|
|
257
266
|
const record = { ...(input.voiceBlob ? { voiceData: { bytes: await input.voiceBlob.arrayBuffer(), type: input.voiceBlob.type } } : {}), scope: this.scope, threadId, hash: message.hash, message, status: "sending", accessVersion: this.version };
|
|
258
|
-
await this.withEpoch("rw", async () => {
|
|
267
|
+
const durable = await this.withEpoch("rw", async () => {
|
|
268
|
+
if (command) {
|
|
269
|
+
const previous = await this.outbox.get([this.scope, message.hash]);
|
|
270
|
+
if (previous && (previous.accessVersion ?? 0) === this.version) {
|
|
271
|
+
if (previous.message.from !== this.identity.profileId || previous.message.hash !== message.hash || commandId(this.scope, this.version, threadId, previous.message) !== message.hash)
|
|
272
|
+
throw new Error("OUTGOING_COMMAND_CONFLICT");
|
|
273
|
+
return previous;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
await this.outbox.put(record);
|
|
277
|
+
return record;
|
|
278
|
+
});
|
|
259
279
|
await this.checkAccess();
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
280
|
+
if (command && durable === record)
|
|
281
|
+
this.firstDelivery.add(record.hash);
|
|
282
|
+
if (durable.confirmed) {
|
|
283
|
+
this.firstDelivery.delete(durable.hash);
|
|
284
|
+
if (this.values.delete(durable.hash))
|
|
285
|
+
this.emit();
|
|
286
|
+
}
|
|
287
|
+
else {
|
|
288
|
+
const current = this.values.get(durable.hash);
|
|
289
|
+
if (!current || current.status !== durable.status || current.accessVersion !== durable.accessVersion) {
|
|
290
|
+
this.values.set(durable.hash, durable);
|
|
291
|
+
this.emit();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return durable.message;
|
|
263
295
|
})().finally(() => this.accepted.delete(pending));
|
|
264
296
|
this.accepted.add(pending);
|
|
265
297
|
return pending;
|
|
@@ -279,8 +311,10 @@ export class DurableChatSender {
|
|
|
279
311
|
let record = await this.withEpoch("r", async () => this.outbox.get([this.scope, hash]));
|
|
280
312
|
if (!record || this.closed || this.withdrawn)
|
|
281
313
|
throw new Error("OUTGOING_MESSAGE_UNAVAILABLE");
|
|
282
|
-
if (record.status === "sent" || record.confirmed)
|
|
314
|
+
if (record.status === "sent" || record.confirmed) {
|
|
315
|
+
this.firstDelivery.delete(hash);
|
|
283
316
|
return record.message;
|
|
317
|
+
}
|
|
284
318
|
const sending = { ...record, status: "sending" };
|
|
285
319
|
if (record.status !== "sending") {
|
|
286
320
|
const latest = await this.save(sending);
|
|
@@ -292,7 +326,15 @@ export class DurableChatSender {
|
|
|
292
326
|
this.values.set(hash, sending);
|
|
293
327
|
this.emit();
|
|
294
328
|
}
|
|
329
|
+
let transmitting = false;
|
|
295
330
|
try {
|
|
331
|
+
if (statefulGroup(record.message) && !this.firstDelivery.delete(hash)) {
|
|
332
|
+
const confirmed = await this.reconcile(record);
|
|
333
|
+
if (confirmed) {
|
|
334
|
+
await this.confirm([hash]);
|
|
335
|
+
return record.message;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
296
338
|
if (hasVoice(record)) {
|
|
297
339
|
record = await this.prepareVoice(record);
|
|
298
340
|
if (record.confirmed || record.status === "sent")
|
|
@@ -301,6 +343,7 @@ export class DurableChatSender {
|
|
|
301
343
|
await this.checkAccess();
|
|
302
344
|
if (this.closing)
|
|
303
345
|
throw new Error("CHAT_SENDER_CLOSED");
|
|
346
|
+
transmitting = true;
|
|
304
347
|
const message = await this.client.sendPrepared(record.message);
|
|
305
348
|
const sent = { ...record, message, status: "sent" };
|
|
306
349
|
const committed = await this.save(sent);
|
|
@@ -314,7 +357,20 @@ export class DurableChatSender {
|
|
|
314
357
|
return message;
|
|
315
358
|
}
|
|
316
359
|
catch (error) {
|
|
317
|
-
|
|
360
|
+
// A failed delivery can already be durable remotely, including the first
|
|
361
|
+
// lost ACK. Proof recovery never sends a second wire in this attempt.
|
|
362
|
+
if (statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
|
|
363
|
+
try {
|
|
364
|
+
if (await this.reconcile(record)) {
|
|
365
|
+
await this.confirm([hash]);
|
|
366
|
+
return record.message;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (verificationError) {
|
|
370
|
+
error = new AggregateError([error, verificationError], "OUTBOX_RECONCILIATION_FAILED");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!this.closed && !this.withdrawn && !(this.closing && statefulGroup(record.message))) {
|
|
318
374
|
const failed = { ...record, status: "failed" };
|
|
319
375
|
const committed = await this.save(failed);
|
|
320
376
|
if (committed?.confirmed || committed?.status === "sent") {
|
|
@@ -331,6 +387,30 @@ export class DurableChatSender {
|
|
|
331
387
|
throw error;
|
|
332
388
|
}
|
|
333
389
|
}
|
|
390
|
+
async reconcile(record) {
|
|
391
|
+
await this.checkAccess();
|
|
392
|
+
if (this.closing || this.closed || this.withdrawn)
|
|
393
|
+
throw new Error("OUTBOX_RECONCILIATION_ABORTED");
|
|
394
|
+
if (!this.options.reconcile)
|
|
395
|
+
throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
|
|
396
|
+
const controller = new AbortController();
|
|
397
|
+
this.reconciliations.add(controller);
|
|
398
|
+
let abort;
|
|
399
|
+
const stopped = new Promise((_, reject) => { abort = () => reject(new Error("OUTBOX_RECONCILIATION_ABORTED")); controller.signal.addEventListener("abort", abort, { once: true }); });
|
|
400
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
401
|
+
try {
|
|
402
|
+
const confirmed = await Promise.race([this.options.reconcile({ threadId: record.threadId, message: record.message }, controller.signal), stopped]);
|
|
403
|
+
await this.checkAccess();
|
|
404
|
+
if (this.closing || controller.signal.aborted)
|
|
405
|
+
throw new Error("OUTBOX_RECONCILIATION_ABORTED");
|
|
406
|
+
return confirmed;
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
controller.signal.removeEventListener("abort", abort);
|
|
411
|
+
this.reconciliations.delete(controller);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
334
414
|
releaseUrl(hash) { const value = this.localUrls.get(hash); if (value) {
|
|
335
415
|
URL.revokeObjectURL(value.url);
|
|
336
416
|
this.localUrls.delete(hash);
|
|
@@ -533,6 +613,7 @@ export class DurableChatSender {
|
|
|
533
613
|
async close() {
|
|
534
614
|
this.closing = true;
|
|
535
615
|
this.uploads.forEach(controller => controller.abort());
|
|
616
|
+
this.reconciliations.forEach(controller => controller.abort());
|
|
536
617
|
this.offConnection();
|
|
537
618
|
this.listeners.clear();
|
|
538
619
|
this.channel?.close();
|
|
@@ -545,6 +626,7 @@ export class DurableChatSender {
|
|
|
545
626
|
finally {
|
|
546
627
|
this.closed = true;
|
|
547
628
|
this.failConfirmations(new Error("CHAT_SENDER_CLOSED"));
|
|
629
|
+
this.firstDelivery.clear();
|
|
548
630
|
this.releaseUrls();
|
|
549
631
|
this.values.clear();
|
|
550
632
|
this.snapshots.clear();
|
|
@@ -552,6 +634,17 @@ export class DurableChatSender {
|
|
|
552
634
|
}
|
|
553
635
|
}
|
|
554
636
|
}
|
|
637
|
+
function statefulGroup(message) { const body = message.body; return body?.kind === "group" && (body.action === "groupCreate" || body.action === "groupUpdate"); }
|
|
638
|
+
function commandId(scope, version, threadId, message) {
|
|
639
|
+
return bytesToHex(blake3(new TextEncoder().encode(JSON.stringify(["chat-command:v1", scope, version, threadId, message.to, ordered(message.body)]))));
|
|
640
|
+
}
|
|
641
|
+
function ordered(value) {
|
|
642
|
+
if (Array.isArray(value))
|
|
643
|
+
return value.map(ordered);
|
|
644
|
+
if (value && typeof value === "object")
|
|
645
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, item]) => [key, ordered(item)]));
|
|
646
|
+
return value;
|
|
647
|
+
}
|
|
555
648
|
function validVoice(value) { const voice = value; return Boolean(voice && typeof voice.url === "string" && Number.isFinite(voice.durationMs) && voice.durationMs > 0); }
|
|
556
649
|
function withVoiceUrl(message, url) {
|
|
557
650
|
const body = message.body;
|
|
@@ -41,6 +41,7 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
41
41
|
private allListeners;
|
|
42
42
|
private reads;
|
|
43
43
|
private syncing;
|
|
44
|
+
private completedSyncs;
|
|
44
45
|
private paging;
|
|
45
46
|
private writes;
|
|
46
47
|
private activeWrites;
|
|
@@ -72,6 +73,8 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
72
73
|
allowed: boolean;
|
|
73
74
|
}>;
|
|
74
75
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
76
|
+
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
77
|
+
cachedMessage(thread: string, id: string): Promise<T>;
|
|
75
78
|
subscribe(thread: string, listener: () => void): () => void;
|
|
76
79
|
subscribeAll(listener: (threadId: string) => void): () => void;
|
|
77
80
|
/** Close the connection gap for saved windows with four bounded background lanes. */
|
|
@@ -11,6 +11,7 @@ export class MessageClient {
|
|
|
11
11
|
allListeners = new Set();
|
|
12
12
|
reads = new Map();
|
|
13
13
|
syncing = new Map();
|
|
14
|
+
completedSyncs = new Map();
|
|
14
15
|
paging = new Map();
|
|
15
16
|
writes = new Map();
|
|
16
17
|
activeWrites = new Set();
|
|
@@ -100,6 +101,16 @@ export class MessageClient {
|
|
|
100
101
|
return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
|
|
101
102
|
}
|
|
102
103
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
104
|
+
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
105
|
+
async cachedMessage(thread, id) {
|
|
106
|
+
await this.ensureScope();
|
|
107
|
+
if (this.closed || this.closing || this.scopeDenied || this.revoked.has(thread))
|
|
108
|
+
throw accessRevoked();
|
|
109
|
+
const page = await this.storage.readSelected(thread, [id]);
|
|
110
|
+
if (page.coverage?.revoked || (page.coverage?.scopeVersion ?? 0) !== this.scopeVersion)
|
|
111
|
+
throw accessRevoked();
|
|
112
|
+
return page.messages[0];
|
|
113
|
+
}
|
|
103
114
|
subscribe(thread, listener) {
|
|
104
115
|
let watch = this.watches.get(thread);
|
|
105
116
|
if (!watch) {
|
|
@@ -137,6 +148,7 @@ export class MessageClient {
|
|
|
137
148
|
if (running)
|
|
138
149
|
return running;
|
|
139
150
|
const task = (async () => {
|
|
151
|
+
const completedAtStart = this.completedSyncs.get(thread);
|
|
140
152
|
await this.ensureScope();
|
|
141
153
|
if (this.scopeDenied)
|
|
142
154
|
throw accessRevoked();
|
|
@@ -197,6 +209,7 @@ export class MessageClient {
|
|
|
197
209
|
if (this.scopeDenied || this.revoked.has(thread))
|
|
198
210
|
throw accessRevoked();
|
|
199
211
|
if (local.coverage?.complete && Date.now() - (local.coverage.syncedAt ?? 0) > (this.options.freshnessMs ?? 30_000)
|
|
212
|
+
&& !this.syncing.has(thread) && this.completedSyncs.get(thread) === completedAtStart
|
|
200
213
|
&& (typeof navigator === "undefined" || navigator.onLine !== false))
|
|
201
214
|
this.invalidate(thread);
|
|
202
215
|
return limit === 100 ? snapshot : result;
|
|
@@ -483,6 +496,7 @@ export class MessageClient {
|
|
|
483
496
|
this.assertNetworkAvailable(thread);
|
|
484
497
|
page = await this.options.transport.changes(thread, after, this.abort.signal);
|
|
485
498
|
}
|
|
499
|
+
this.completedSyncs.set(thread, (this.completedSyncs.get(thread) ?? 0) + 1);
|
|
486
500
|
})().catch(async (error) => {
|
|
487
501
|
if (String(error).includes("MESSAGE_SCOPE_CHANGED"))
|
|
488
502
|
this.denyScope();
|
|
@@ -676,6 +690,7 @@ export class MessageClient {
|
|
|
676
690
|
this.channel?.close();
|
|
677
691
|
this.watches.clear();
|
|
678
692
|
this.windowEdges.clear();
|
|
693
|
+
this.completedSyncs.clear();
|
|
679
694
|
this.allListeners.clear();
|
|
680
695
|
this.accessListeners.clear();
|
|
681
696
|
this.storage.close();
|
|
@@ -127,6 +127,9 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
127
127
|
constructor(factory?: IDBFactory, name?: string, storageScope?: string | undefined, migrateLegacy?: boolean, metadataOnly?: boolean);
|
|
128
128
|
private transaction;
|
|
129
129
|
private key;
|
|
130
|
+
/** Keyset pages avoid materializing a second, unbounded array of durable rows.
|
|
131
|
+
* Keep the caller's transaction so every page belongs to one consistent view. */
|
|
132
|
+
private readPages;
|
|
130
133
|
private hydrate;
|
|
131
134
|
private loadCurrent;
|
|
132
135
|
private loadStored;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -269,22 +269,33 @@ export class IndexedDbReadIndex {
|
|
|
269
269
|
throw new Error("READ_STORAGE_UNAVAILABLE");
|
|
270
270
|
}
|
|
271
271
|
key(appId, profileId) { return this.storageScope === undefined ? [appId, profileId] : [appId, profileId, this.storageScope]; }
|
|
272
|
+
/** Keyset pages avoid materializing a second, unbounded array of durable rows.
|
|
273
|
+
* Keep the caller's transaction so every page belongs to one consistent view. */
|
|
274
|
+
async readPages(table, owner, key, visit) {
|
|
275
|
+
let after;
|
|
276
|
+
for (;;) {
|
|
277
|
+
const rows = await table.where(":id").between(after ?? [owner, Dexie.minKey], [owner, Dexie.maxKey], !after, true).limit(128).toArray();
|
|
278
|
+
for (const row of rows)
|
|
279
|
+
visit(row);
|
|
280
|
+
if (rows.length < 128)
|
|
281
|
+
return;
|
|
282
|
+
after = key(rows[rows.length - 1]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
272
285
|
async hydrate(current, key) {
|
|
273
286
|
if (current.normalized) {
|
|
274
287
|
const owner = JSON.stringify(key);
|
|
275
|
-
const [threads, messages, positions] = await Promise.all([
|
|
276
|
-
this.threadRows.where("owner").equals(owner).toArray(),
|
|
277
|
-
this.messageRows.where("owner").equals(owner).toArray(),
|
|
278
|
-
this.positionRows.where("owner").equals(owner).toArray(),
|
|
279
|
-
]);
|
|
280
288
|
const byThread = new Map();
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
289
|
+
const byPosition = new Map(), byId = new Map();
|
|
290
|
+
await Promise.all([
|
|
291
|
+
this.readPages(this.threadRows, owner, row => [row.owner, row.id], row => byId.set(row.id, row)),
|
|
292
|
+
this.readPages(this.messageRows, owner, row => [row.owner, row.thread, row.id], row => {
|
|
293
|
+
if (!byThread.has(row.thread))
|
|
294
|
+
byThread.set(row.thread, new Map());
|
|
295
|
+
byThread.get(row.thread).set(row.id, row.value);
|
|
296
|
+
}),
|
|
297
|
+
this.readPages(this.positionRows, owner, row => [row.owner, row.id], row => byPosition.set(row.id, row)),
|
|
298
|
+
]);
|
|
288
299
|
const { normalized: _normalized, threadOrder, ...state } = current;
|
|
289
300
|
const result = { ...state, threads: (threadOrder ?? []).map(id => {
|
|
290
301
|
const row = byId.get(id);
|
|
@@ -494,8 +505,8 @@ export class IndexedDbReadIndex {
|
|
|
494
505
|
// Cursors are the mutable cross-document view. Refresh their small rows,
|
|
495
506
|
// while message metadata remains targeted; otherwise a later batch based
|
|
496
507
|
// on an older snapshot could regress a different conversation's progress.
|
|
497
|
-
const
|
|
498
|
-
|
|
508
|
+
const positionById = new Map();
|
|
509
|
+
await this.readPages(this.positionRows, owner, row => [row.owner, row.id], row => positionById.set(row.id, row));
|
|
499
510
|
if (base.threads.some(thread => !positionById.has(thread.id)))
|
|
500
511
|
return undefined;
|
|
501
512
|
const positions = sourceIds.map(id => positionById.get(id));
|
|
@@ -538,7 +549,7 @@ export class IndexedDbReadIndex {
|
|
|
538
549
|
await this.positionRows.bulkPut(writes);
|
|
539
550
|
if (JSON.stringify(header.pending) !== JSON.stringify(result.pending) || JSON.stringify(header.observedReadIntents) !== JSON.stringify(result.observedReadIntents))
|
|
540
551
|
await this.profiles.put({ ...header, pending: result.pending, observedReadIntents: result.observedReadIntents }, key);
|
|
541
|
-
const byId = new Map(
|
|
552
|
+
const byId = new Map(positionById);
|
|
542
553
|
result.threads.forEach(thread => byId.set(thread.id, thread));
|
|
543
554
|
let changed = false;
|
|
544
555
|
const threads = base.threads.map(thread => {
|
|
@@ -26,17 +26,21 @@ export class SocialReadClient {
|
|
|
26
26
|
}
|
|
27
27
|
async messagePage(input, signal) {
|
|
28
28
|
this.requireTeam(input.teamId);
|
|
29
|
-
const limit = input.limit ?? 100;
|
|
29
|
+
const limit = input.limit ?? (input.messageId === undefined ? 100 : 1);
|
|
30
30
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500 || (input.before !== undefined && input.after !== undefined))
|
|
31
31
|
throw new SocialReadError(400, "INVALID_MESSAGE_PAGE");
|
|
32
|
+
if (input.messageId !== undefined && (!input.messageId || input.messageId.length > 256 || /[\u0000-\u001f\u007f]/.test(input.messageId) || limit !== 1 || input.before !== undefined || input.after !== undefined))
|
|
33
|
+
throw new SocialReadError(400, "INVALID_MESSAGE_LOOKUP");
|
|
32
34
|
const value = await this.request("messages", { threadId: input.threadId, ...this.teamQuery(input.teamId), limit: String(limit),
|
|
33
|
-
...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }) }, signal);
|
|
35
|
+
...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }), ...(input.messageId === undefined ? {} : { messageId: input.messageId }) }, signal);
|
|
34
36
|
const cursor = (token) => typeof token === "string" && /^mp1:\d+:[0-9a-f]{64}$/.test(token);
|
|
35
37
|
if (!value || !Array.isArray(value.messages) || value.messages.length > limit || !cursor(value.checkpoint)
|
|
36
38
|
|| (value.nextBefore !== null && !cursor(value.nextBefore)) || typeof value.hasMoreChanges !== "boolean" || typeof value.resetRequired !== "boolean"
|
|
37
39
|
|| value.messages.some(row => row.appId !== this.options.appId || row.threadId !== input.threadId || row.teamId !== input.teamId || !row.messageId || !Number.isFinite(row.serverCreatedAt))
|
|
38
40
|
|| (value.resetRequired && (value.messages.length || value.hasMoreChanges || value.nextBefore !== null)))
|
|
39
41
|
throw new SocialReadError(502, "INVALID_MESSAGE_PAGE_RESPONSE");
|
|
42
|
+
if (input.messageId !== undefined && (value.messages.some(row => row.messageId !== input.messageId) || value.nextBefore !== null || value.hasMoreChanges || value.resetRequired))
|
|
43
|
+
throw new SocialReadError(502, "INVALID_MESSAGE_LOOKUP_RESPONSE");
|
|
40
44
|
return value;
|
|
41
45
|
}
|
|
42
46
|
group(input) {
|
package/package.json
CHANGED