@pmate/chat-sdk 0.12.0-next.66 → 0.12.0-next.68
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/messageView.js +1 -1
- package/lib/runtime/chatError.js +1 -1
- package/lib/runtime/chatSession.js +30 -7
- package/lib/runtime/conversationDirectory.d.ts +1 -0
- package/lib/runtime/conversationDirectory.js +7 -7
- package/lib/runtime/createChat.js +1 -1
- package/lib/runtime/messageClient.d.ts +5 -0
- package/lib/runtime/messageClient.js +32 -0
- package/lib/runtime/messageStore.d.ts +2 -0
- package/lib/runtime/messageStore.js +12 -0
- package/lib/runtime/responseCache.d.ts +2 -0
- package/lib/runtime/responseCache.js +13 -11
- package/lib/runtime/socialReadClient.d.ts +1 -1
- package/lib/runtime/socialReadClient.js +4 -4
- package/package.json +1 -1
package/lib/messageView.js
CHANGED
|
@@ -4,7 +4,7 @@ function media(value) {
|
|
|
4
4
|
if (typeof value !== "string" || !value)
|
|
5
5
|
return undefined;
|
|
6
6
|
try {
|
|
7
|
-
const url = new URL(value);
|
|
7
|
+
const url = new URL(value, "https://chat.invalid");
|
|
8
8
|
return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password ? value : undefined;
|
|
9
9
|
}
|
|
10
10
|
catch {
|
package/lib/runtime/chatError.js
CHANGED
|
@@ -20,7 +20,7 @@ export function asChatError(error) {
|
|
|
20
20
|
: /CLOSED/.test(message) ? "closed"
|
|
21
21
|
: status === 409 || /REVISION_CONFLICT|PROJECTION_CHANGED/.test(message) ? "conflict"
|
|
22
22
|
: /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
|
|
23
|
-
: status === 400 || /INVALID_|CONVERSATION_MISMATCH|NOT_OWNED/.test(message) ? "invalid-input"
|
|
23
|
+
: status === 400 || /INVALID_|CONVERSATION_MISMATCH|CONVERSATION_UNKNOWN|NOT_OWNED/.test(message) ? "invalid-input"
|
|
24
24
|
: /QuotaExceeded|DatabaseClosed|UnknownError|AbortError/.test(value?.name ?? "") || /STORAGE_|DATABASE_|CACHE_WRITE/.test(message) ? "storage-unavailable" : "unavailable";
|
|
25
25
|
return new ChatError(code, message, { cause: error, status });
|
|
26
26
|
}
|
|
@@ -415,7 +415,19 @@ export class ChatSession {
|
|
|
415
415
|
return (delivered ? this.sender.send(threadId, wire) : this.sender.enqueue(threadId, wire)).then(next => { if (this.withdrawn)
|
|
416
416
|
throw new Error("CHAT_ACCESS_REVOKED"); return next; });
|
|
417
417
|
}
|
|
418
|
-
getConversation(target) {
|
|
418
|
+
async getConversation(target) {
|
|
419
|
+
this.assertOpen();
|
|
420
|
+
try {
|
|
421
|
+
return await this.conversations.create(target);
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
if (error?.message !== "CHAT_ACCESS_REVOKED")
|
|
425
|
+
throw error;
|
|
426
|
+
await this.messages.authorizeScope();
|
|
427
|
+
this.assertOpen();
|
|
428
|
+
return this.conversations.create(target);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
419
431
|
async sendContent(target, input, delivered) {
|
|
420
432
|
let thread = typeof target === "string" ? await this.resolveConversation(target) : target;
|
|
421
433
|
validateConversation(this.options.messages.scope, thread);
|
|
@@ -444,21 +456,32 @@ export class ChatSession {
|
|
|
444
456
|
/** Validated target identity survives content redaction; no roster is exposed. */
|
|
445
457
|
getConversationTarget(threadId) {
|
|
446
458
|
const scope = this.options.messages.scope;
|
|
447
|
-
const
|
|
459
|
+
const snapshot = this.getSnapshot(threadId), group = snapshot.group;
|
|
448
460
|
if (group)
|
|
449
461
|
return { id: threadId, kind: "group", targetId: group.groupId, teamId: scope.teamId };
|
|
450
|
-
|
|
451
|
-
const row = value;
|
|
462
|
+
const infer = (row) => {
|
|
452
463
|
const kind = row.threadKind ?? row.body?.threadKind;
|
|
453
464
|
if ((kind !== "group" && kind !== "dm") || !row.to || !row.from)
|
|
454
|
-
|
|
465
|
+
return undefined;
|
|
455
466
|
const thread = { id: threadId, kind, targetId: kind === "group" || row.from === scope.profileId ? row.to : row.from, teamId: scope.teamId };
|
|
456
|
-
// A stored row is only a hint; validate the canonical scoped identifier.
|
|
457
467
|
try {
|
|
458
468
|
validateConversation(scope, thread);
|
|
459
469
|
return thread;
|
|
460
470
|
}
|
|
461
|
-
catch {
|
|
471
|
+
catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
for (const value of snapshot.messages) {
|
|
476
|
+
const thread = infer(value);
|
|
477
|
+
if (thread)
|
|
478
|
+
return thread;
|
|
479
|
+
}
|
|
480
|
+
// Older integrations may have a durable send but no registered target or indexed history yet.
|
|
481
|
+
for (const value of snapshot.outgoing) {
|
|
482
|
+
const thread = infer(value.message);
|
|
483
|
+
if (thread)
|
|
484
|
+
return thread;
|
|
462
485
|
}
|
|
463
486
|
return undefined;
|
|
464
487
|
}
|
|
@@ -30,6 +30,7 @@ export declare class ConversationDirectory {
|
|
|
30
30
|
put(thread: ChatConversation): Promise<ChatConversation>;
|
|
31
31
|
private persist;
|
|
32
32
|
get(id: string): Promise<ChatConversation | undefined>;
|
|
33
|
+
private lookup;
|
|
33
34
|
private flush;
|
|
34
35
|
retire(version: number): Promise<void>;
|
|
35
36
|
close(): Promise<void>;
|
|
@@ -19,7 +19,7 @@ export class ConversationDirectory {
|
|
|
19
19
|
constructor(scope, access) {
|
|
20
20
|
this.scope = scope;
|
|
21
21
|
this.access = access;
|
|
22
|
-
this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access: () => this.authorize(), entries: 256, recordBytes: 4096 });
|
|
22
|
+
this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access: () => this.authorize(), entries: 256, recordBytes: 4096, retainAll: true });
|
|
23
23
|
}
|
|
24
24
|
authorize() { return this.authority ??= this.access().finally(() => { this.authority = undefined; }); }
|
|
25
25
|
assertOpen() { if (this.retired)
|
|
@@ -56,18 +56,18 @@ export class ConversationDirectory {
|
|
|
56
56
|
this.assertOpen();
|
|
57
57
|
validateConversation(this.scope, thread);
|
|
58
58
|
const value = { id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: this.scope.teamId };
|
|
59
|
-
const
|
|
59
|
+
const row = await this.lookup(thread.id), previous = row.value;
|
|
60
60
|
if (previous) {
|
|
61
61
|
if (previous.kind !== value.kind || previous.targetId !== value.targetId)
|
|
62
62
|
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
63
63
|
return previous;
|
|
64
64
|
}
|
|
65
|
-
const row = await this.cache.read(thread.id);
|
|
66
65
|
await this.cache.write(thread.id, value, row.epoch);
|
|
67
66
|
this.assertOpen();
|
|
68
67
|
return this.remember(value);
|
|
69
68
|
}
|
|
70
|
-
async get(id) {
|
|
69
|
+
async get(id) { return (await this.lookup(id)).value; }
|
|
70
|
+
async lookup(id) {
|
|
71
71
|
this.assertOpen();
|
|
72
72
|
const authority = await this.authorize();
|
|
73
73
|
this.assertOpen();
|
|
@@ -78,7 +78,7 @@ export class ConversationDirectory {
|
|
|
78
78
|
this.version = authority.version;
|
|
79
79
|
const known = this.memory.get(id);
|
|
80
80
|
if (known)
|
|
81
|
-
return this.remember(known);
|
|
81
|
+
return { value: this.remember(known), epoch: 0 };
|
|
82
82
|
const existing = this.pending.get(id);
|
|
83
83
|
if (existing)
|
|
84
84
|
return existing;
|
|
@@ -98,13 +98,13 @@ export class ConversationDirectory {
|
|
|
98
98
|
const rows = await this.cache.readMany(batch.map(item => item.id));
|
|
99
99
|
this.assertOpen();
|
|
100
100
|
for (const item of batch) {
|
|
101
|
-
const
|
|
101
|
+
const row = rows.get(item.id), value = row.cached?.value;
|
|
102
102
|
if (value) {
|
|
103
103
|
if (value.id !== item.id)
|
|
104
104
|
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
105
105
|
validateConversation(this.scope, value);
|
|
106
106
|
}
|
|
107
|
-
item.resolve(value ? this.remember(value) : undefined);
|
|
107
|
+
item.resolve({ value: value ? this.remember(value) : undefined, epoch: row.epoch });
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
catch (error) {
|
|
@@ -34,7 +34,7 @@ export function createChat(options) {
|
|
|
34
34
|
},
|
|
35
35
|
loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
|
|
36
36
|
activeThreadId: options.threadId,
|
|
37
|
-
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 },
|
|
37
|
+
messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
38
38
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
39
39
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
|
|
40
40
|
session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
|
|
@@ -7,6 +7,8 @@ export type MessagePage<T> = {
|
|
|
7
7
|
resetRequired?: boolean;
|
|
8
8
|
};
|
|
9
9
|
export type MessageTransport<T> = {
|
|
10
|
+
/** Optional team-scoped authorization for fresh instances recovering a revoked cache. */
|
|
11
|
+
authorizeScope?: (signal: AbortSignal) => Promise<void>;
|
|
10
12
|
/** Authoritative history. Must include control state needed to merge old rows. */
|
|
11
13
|
load: (threadId: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
12
14
|
changes?: (threadId: string, after: string, signal: AbortSignal) => Promise<MessagePage<T>>;
|
|
@@ -72,6 +74,9 @@ export declare class MessageClient<T extends StoredMessage> {
|
|
|
72
74
|
version: number;
|
|
73
75
|
allowed: boolean;
|
|
74
76
|
}>;
|
|
77
|
+
private authorizingScope?;
|
|
78
|
+
/** Recovery belongs to the SDK; withdrawn instances never regain authority. */
|
|
79
|
+
authorizeScope(): Promise<void>;
|
|
75
80
|
getSnapshot(thread: string): MessageSnapshot<T> | undefined;
|
|
76
81
|
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
77
82
|
cachedMessage(thread: string, id: string): Promise<T>;
|
|
@@ -100,6 +100,38 @@ export class MessageClient {
|
|
|
100
100
|
const current = await this.storage.scopeAccess();
|
|
101
101
|
return { version: current.version, allowed: !this.scopeDenied && !current.revoked && current.version === this.scopeVersion };
|
|
102
102
|
}
|
|
103
|
+
authorizingScope;
|
|
104
|
+
/** Recovery belongs to the SDK; withdrawn instances never regain authority. */
|
|
105
|
+
authorizeScope() {
|
|
106
|
+
return this.authorizingScope ??= (async () => {
|
|
107
|
+
await this.ensureScope();
|
|
108
|
+
if (this.closed || this.closing || this.scopeDenied)
|
|
109
|
+
throw accessRevoked();
|
|
110
|
+
const access = await this.scopeAccess();
|
|
111
|
+
if (access.allowed)
|
|
112
|
+
return;
|
|
113
|
+
if (access.version !== this.scopeVersion) {
|
|
114
|
+
this.denyScope();
|
|
115
|
+
throw accessRevoked();
|
|
116
|
+
}
|
|
117
|
+
if (!this.options.transport.authorizeScope)
|
|
118
|
+
throw accessRevoked();
|
|
119
|
+
try {
|
|
120
|
+
await this.options.transport.authorizeScope(this.abort.signal);
|
|
121
|
+
if (this.closed || this.closing || this.scopeDenied)
|
|
122
|
+
throw accessRevoked();
|
|
123
|
+
await this.storage.authorizeScope(this.scopeVersion);
|
|
124
|
+
if (this.closed || this.closing || this.scopeDenied)
|
|
125
|
+
throw accessRevoked();
|
|
126
|
+
this.scopeAllowed = true;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if ([401, 403].includes(error?.status ?? 0) || String(error).includes("MESSAGE_SCOPE_CHANGED"))
|
|
130
|
+
this.denyScope();
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
})().finally(() => { this.authorizingScope = undefined; });
|
|
134
|
+
}
|
|
103
135
|
getSnapshot(thread) { return this.watches.get(thread)?.snapshot; }
|
|
104
136
|
/** Exact authoritative row only; migration-only rows never prove delivery. */
|
|
105
137
|
async cachedMessage(thread, id) {
|
|
@@ -82,6 +82,8 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
82
82
|
private finishPage;
|
|
83
83
|
windows(): Promise<CoverageRow[]>;
|
|
84
84
|
scopeAccess(): Promise<MessageScopeAccess>;
|
|
85
|
+
/** Only fresh, server-confirmed authorization may lift this durable tombstone. */
|
|
86
|
+
authorizeScope(expectedVersion: number): Promise<void>;
|
|
85
87
|
private scopedCoverage;
|
|
86
88
|
private clearScopeRows;
|
|
87
89
|
/** Persist the denial first; failed cleanup must never make cached rows readable. */
|
|
@@ -230,6 +230,18 @@ export class IndexedDbMessageStore {
|
|
|
230
230
|
throw new Error("INVALID_MESSAGE_SCOPE_ACCESS");
|
|
231
231
|
return access;
|
|
232
232
|
}
|
|
233
|
+
/** Only fresh, server-confirmed authorization may lift this durable tombstone. */
|
|
234
|
+
async authorizeScope(expectedVersion) {
|
|
235
|
+
await this.db.transaction("rw", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
236
|
+
const access = await this.scopeAccess();
|
|
237
|
+
if (access.version !== expectedVersion)
|
|
238
|
+
throw new Error("MESSAGE_SCOPE_CHANGED");
|
|
239
|
+
if (!access.revoked)
|
|
240
|
+
return;
|
|
241
|
+
await this.clearScopeRows();
|
|
242
|
+
await this.accessRows.put({ ...access, revoked: false });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
233
245
|
scopedCoverage(coverage, access) {
|
|
234
246
|
return access.version ? { ...coverage, complete: coverage?.complete ?? false, scopeVersion: access.version } : coverage;
|
|
235
247
|
}
|
|
@@ -8,6 +8,8 @@ export type ResponseCacheOptions<T> = {
|
|
|
8
8
|
indexedDB?: IDBFactory;
|
|
9
9
|
name?: string;
|
|
10
10
|
access?: ResponseCacheAccess;
|
|
11
|
+
/** Immutable identity metadata must survive ordinary response-window eviction. */
|
|
12
|
+
retainAll?: boolean;
|
|
11
13
|
recordBytes?: number;
|
|
12
14
|
entries?: number;
|
|
13
15
|
batchRecords?: number;
|
|
@@ -59,7 +59,7 @@ export class IndexedDbResponseCache {
|
|
|
59
59
|
throw new Error("RESPONSE_CACHE_SCOPE_CHANGED");
|
|
60
60
|
if (access.version > version) {
|
|
61
61
|
await this.epochs.put({ scope: this.scope, epoch: (current?.epoch ?? 0) + 1, accessVersion: access.version });
|
|
62
|
-
await this.rows.where("scope").
|
|
62
|
+
await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
65
|
if (!access.allowed)
|
|
@@ -114,7 +114,7 @@ export class IndexedDbResponseCache {
|
|
|
114
114
|
if ((current?.accessVersion ?? 0) >= version)
|
|
115
115
|
return;
|
|
116
116
|
await this.epochs.put({ scope: this.scope, epoch: (current?.epoch ?? 0) + 1, accessVersion: version });
|
|
117
|
-
await this.rows.where("scope").
|
|
117
|
+
await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
118
118
|
});
|
|
119
119
|
this.invalidations.add(task);
|
|
120
120
|
return task.finally(() => this.invalidations.delete(task));
|
|
@@ -127,7 +127,7 @@ export class IndexedDbResponseCache {
|
|
|
127
127
|
const current = await this.epochs.get(this.scope);
|
|
128
128
|
const epoch = (current?.epoch ?? 0) + 1;
|
|
129
129
|
await this.epochs.put({ scope: this.scope, epoch, accessVersion: current?.accessVersion });
|
|
130
|
-
await this.rows.where("scope").
|
|
130
|
+
await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
|
|
131
131
|
});
|
|
132
132
|
})();
|
|
133
133
|
this.invalidations.add(task);
|
|
@@ -178,14 +178,16 @@ export class IndexedDbResponseCache {
|
|
|
178
178
|
}
|
|
179
179
|
if (unique.size) {
|
|
180
180
|
await this.rows.bulkPut([...unique.values()]);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
181
|
+
if (!this.options.retainAll) {
|
|
182
|
+
const stored = await this.rows.where("scope").equals(this.scope).toArray();
|
|
183
|
+
stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
|
|
184
|
+
let bytes = 0;
|
|
185
|
+
const evicted = [];
|
|
186
|
+
stored.forEach((row, index) => { bytes += row.bytes; if (index >= (this.options.entries ?? 256) || bytes > 1048576)
|
|
187
|
+
evicted.push([this.scope, row.key]); });
|
|
188
|
+
if (evicted.length)
|
|
189
|
+
await this.rows.bulkDelete(evicted);
|
|
190
|
+
}
|
|
189
191
|
}
|
|
190
192
|
return { accepted, changed };
|
|
191
193
|
});
|
|
@@ -46,7 +46,7 @@ export declare class SocialReadClient {
|
|
|
46
46
|
private readonly options;
|
|
47
47
|
constructor(options: SocialReadClientOptions);
|
|
48
48
|
/** Without teamId includes current Teams and unassigned contact metadata for migration. */
|
|
49
|
-
contacts(teamId?: string): Promise<ChatReaderContact[]>;
|
|
49
|
+
contacts(teamId?: string, signal?: AbortSignal): Promise<ChatReaderContact[]>;
|
|
50
50
|
unread(teamId?: string): Promise<ChatReaderUnread[]>;
|
|
51
51
|
messages(input: {
|
|
52
52
|
threadId: string;
|
|
@@ -14,8 +14,8 @@ export class SocialReadClient {
|
|
|
14
14
|
options;
|
|
15
15
|
constructor(options) { this.options = { ...options }; }
|
|
16
16
|
/** Without teamId includes current Teams and unassigned contact metadata for migration. */
|
|
17
|
-
contacts(teamId) {
|
|
18
|
-
return this.list("contacts", this.teamQuery(teamId));
|
|
17
|
+
contacts(teamId, signal) {
|
|
18
|
+
return this.list("contacts", this.teamQuery(teamId), signal);
|
|
19
19
|
}
|
|
20
20
|
unread(teamId) {
|
|
21
21
|
return this.list("unread", this.teamQuery(teamId));
|
|
@@ -58,8 +58,8 @@ export class SocialReadClient {
|
|
|
58
58
|
if (this.options.appId === "pmate:merry" && !teamId)
|
|
59
59
|
throw new SocialReadError(400, "TEAM_ID_REQUIRED");
|
|
60
60
|
}
|
|
61
|
-
async list(action, query) {
|
|
62
|
-
const value = await this.request(action, query);
|
|
61
|
+
async list(action, query, signal) {
|
|
62
|
+
const value = await this.request(action, query, signal);
|
|
63
63
|
if (!Array.isArray(value))
|
|
64
64
|
throw new SocialReadError(502, "INVALID_SOCIAL_READ_RESPONSE");
|
|
65
65
|
return value;
|
package/package.json
CHANGED