@pmate/chat-sdk 0.12.0-next.83 → 0.12.0-next.85
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/README.md +15 -0
- package/lib/internal/chatEngine.js +6 -3
- package/lib/runtime/chatClient.d.ts +1 -1
- package/lib/runtime/chatClient.js +4 -3
- package/lib/runtime/conversationRead.js +1 -1
- package/lib/runtime/conversationReading.js +1 -1
- package/lib/runtime/durableSender.js +2 -1
- package/lib/runtime/groupIdentity.d.ts +4 -0
- package/lib/runtime/groupIdentity.js +26 -0
- package/lib/runtime/readIndex.d.ts +2 -0
- package/lib/runtime/readIndex.js +6 -4
- package/lib/runtime/socialReadClient.d.ts +1 -0
- package/lib/runtime/socialReadClient.js +1 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -338,3 +338,18 @@ A discussion cannot open another discussion. Thread-internal quoted replies keep
|
|
|
338
338
|
the same root. Close each discussion when its view closes; closing the parent
|
|
339
339
|
also closes its discussions. Agents working on a platform supporting Message
|
|
340
340
|
Threads should put their replies in the incoming Message Thread.
|
|
341
|
+
|
|
342
|
+
## App-wide group identity
|
|
343
|
+
|
|
344
|
+
### Background
|
|
345
|
+
|
|
346
|
+
A group is addressed by `(appId, groupId)`. Team membership governs access but is
|
|
347
|
+
not part of the public destination identity. Callers can read group metadata
|
|
348
|
+
without supplying a Team; the Hub resolves the canonical Team and checks access.
|
|
349
|
+
|
|
350
|
+
Both group creation APIs generate a UUID when no ID is supplied. A definitive
|
|
351
|
+
`GROUP_ID_CONFLICT` selects another UUID, with at most eight attempts. Timeouts,
|
|
352
|
+
permission errors and unknown delivery outcomes retain the original identity;
|
|
353
|
+
they must never create a second group. Durable creation retries reuse the same
|
|
354
|
+
creation command. Server-side conditional appends enforce uniqueness across Hub
|
|
355
|
+
instances. Deploy that server contract before enabling Team-free group lookup.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createUniqueGroup } from "../runtime/groupIdentity.js";
|
|
1
2
|
import { createMessagePresenter } from "../messageView.js";
|
|
2
3
|
import { createChat as createSession } from "../runtime/createChat.js";
|
|
3
4
|
import { asChatError } from "../runtime/chatError.js";
|
|
@@ -272,9 +273,11 @@ export class ChatEngine {
|
|
|
272
273
|
getGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.getGroup(threadId, options))); }
|
|
273
274
|
createGroup(input) {
|
|
274
275
|
return this.operation(async () => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
return createUniqueGroup(input.groupId, async (groupId) => {
|
|
277
|
+
const threadId = await this.getConversation({ groupId });
|
|
278
|
+
const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
|
|
279
|
+
return { ...this.group(group), threadId };
|
|
280
|
+
});
|
|
278
281
|
});
|
|
279
282
|
}
|
|
280
283
|
updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createUniqueGroup } from "./groupIdentity.js";
|
|
1
2
|
import { withChatTeam } from "./teamScope.js";
|
|
2
3
|
import { OnlineReachReceiver } from "./reach.js";
|
|
3
4
|
import { OnlineNotificationReceiver } from "./notification.js";
|
|
@@ -232,18 +233,18 @@ export class ChatClient {
|
|
|
232
233
|
});
|
|
233
234
|
}
|
|
234
235
|
async createGroup(input) {
|
|
235
|
-
return this.sendChat(
|
|
236
|
+
return createUniqueGroup(input.groupId, groupId => this.sendChat(groupId, {
|
|
236
237
|
kind: "group",
|
|
237
238
|
threadKind: "group",
|
|
238
239
|
action: "groupCreate",
|
|
239
240
|
payload: {
|
|
240
241
|
appId: this.options.appId,
|
|
241
|
-
groupId
|
|
242
|
+
groupId,
|
|
242
243
|
...(input.teamId !== undefined ? { teamId: input.teamId } : {}),
|
|
243
244
|
members: input.members,
|
|
244
245
|
title: input.title,
|
|
245
246
|
},
|
|
246
|
-
}, input.members);
|
|
247
|
+
}, input.members));
|
|
247
248
|
}
|
|
248
249
|
async updateGroup(input) {
|
|
249
250
|
return this.sendChat(input.groupId, {
|
|
@@ -13,7 +13,7 @@ export function recordConversationRead(state, conversationId, messages = [], ope
|
|
|
13
13
|
indexedIds.add(message.id);
|
|
14
14
|
return (message.threadRootId ?? undefined) === threadRootId && !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
|
|
15
15
|
});
|
|
16
|
-
const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > readingCursor(source, threadRootId))
|
|
16
|
+
const latest = incoming.filter(message => message.readPolicy === "visible" && !source.preservedReadMessageIds?.includes(message.id) && message.seq > readingCursor(source, threadRootId))
|
|
17
17
|
.reduce((last, message) => !last || message.seq > last.seq ? message : last, undefined);
|
|
18
18
|
if (latest)
|
|
19
19
|
next = recordReadIntent(next, { operationId: operationId(), threadId: source.id, messageId: latest.id, readPolicy: "visible" });
|
|
@@ -13,7 +13,7 @@ export function projectConversationReading(state, conversation, candidates = [])
|
|
|
13
13
|
for (const source of sources)
|
|
14
14
|
for (const message of source.messages) {
|
|
15
15
|
indexed.add(message.id);
|
|
16
|
-
messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || hidden.get(message.id)?.has(message.from) === true || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= readingCursor(source, message.threadRootId)) };
|
|
16
|
+
messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: source.preservedReadMessageIds?.includes(message.id) === true || message.recalled || hidden.get(message.id)?.has(message.from) === true || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= readingCursor(source, message.threadRootId)) };
|
|
17
17
|
}
|
|
18
18
|
const observed = new Map();
|
|
19
19
|
for (const intent of state?.observedReadIntents ?? [])
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { groupIdCollision } from "./groupIdentity.js";
|
|
1
2
|
import { Dexie } from "dexie";
|
|
2
3
|
import { validMessageIdentifier } from "./outgoingContent.js";
|
|
3
4
|
import { outboxDatabase, prepareOutboxSchema } from "./outboxSchema.js";
|
|
@@ -500,7 +501,7 @@ export class DurableChatSender {
|
|
|
500
501
|
catch (error) {
|
|
501
502
|
// A failed delivery can already be durable remotely, including the first
|
|
502
503
|
// lost ACK. Proof recovery never sends a second wire in this attempt.
|
|
503
|
-
if (statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
|
|
504
|
+
if (!groupIdCollision(error) && statefulGroup(record.message) && this.options.reconcile && transmitting && !this.closed && !this.closing && !this.withdrawn) {
|
|
504
505
|
try {
|
|
505
506
|
if (await this.reconcile(record)) {
|
|
506
507
|
await this.confirm([hash]);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Only a definitive create rejection may choose a new UUID. Unknown delivery
|
|
2
|
+
* outcomes retain the original message/idempotency identity. */
|
|
3
|
+
export declare function groupIdCollision(error: unknown): boolean;
|
|
4
|
+
export declare function createUniqueGroup<T>(initialId: string | undefined, create: (groupId: string) => Promise<T>, uuid?: () => string): Promise<T>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Only a definitive create rejection may choose a new UUID. Unknown delivery
|
|
2
|
+
* outcomes retain the original message/idempotency identity. */
|
|
3
|
+
export function groupIdCollision(error) {
|
|
4
|
+
let current = error;
|
|
5
|
+
const seen = new Set();
|
|
6
|
+
while (current instanceof Error && !seen.has(current)) {
|
|
7
|
+
seen.add(current);
|
|
8
|
+
if (current.message === "GROUP_ID_CONFLICT")
|
|
9
|
+
return true;
|
|
10
|
+
current = current.cause;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
export async function createUniqueGroup(initialId, create, uuid = () => crypto.randomUUID()) {
|
|
15
|
+
let id = initialId ?? uuid();
|
|
16
|
+
for (let attempt = 0; attempt < 8; attempt++)
|
|
17
|
+
try {
|
|
18
|
+
return await create(id);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (!groupIdCollision(error) || attempt === 7)
|
|
22
|
+
throw error;
|
|
23
|
+
id = uuid();
|
|
24
|
+
}
|
|
25
|
+
throw new Error("GROUP_ID_GENERATION_EXHAUSTED");
|
|
26
|
+
}
|
|
@@ -27,6 +27,8 @@ export type ReadingOperation = {
|
|
|
27
27
|
explicitReadMessageIds: string[];
|
|
28
28
|
};
|
|
29
29
|
export type IndexedReadThread = {
|
|
30
|
+
/** Read facts retained when server-side thread organization moves a message. */
|
|
31
|
+
preservedReadMessageIds?: string[];
|
|
30
32
|
/** Durable source ID: reading positions must remain attached to this ID. */
|
|
31
33
|
id: string;
|
|
32
34
|
teamId?: string;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -50,6 +50,8 @@ function validateThread(thread) {
|
|
|
50
50
|
throw new Error("INVALID_READ_TEAM");
|
|
51
51
|
if (thread.conversationId !== undefined && (!thread.conversationId || !thread.teamId))
|
|
52
52
|
throw new Error("INVALID_READ_CONVERSATION");
|
|
53
|
+
if (thread.preservedReadMessageIds !== undefined && (!Array.isArray(thread.preservedReadMessageIds) || thread.preservedReadMessageIds.some(id => typeof id !== "string" || !id)))
|
|
54
|
+
throw new Error("INVALID_PRESERVED_READ_MESSAGES");
|
|
53
55
|
if (Object.entries(thread.messageThreadReadThrough ?? {}).some(([root, seq]) => !validMessageThreadRoot(root) || !Number.isSafeInteger(seq) || seq < 0))
|
|
54
56
|
throw new Error("INVALID_MESSAGE_THREAD_READING");
|
|
55
57
|
const ids = new Set(), positions = new Set();
|
|
@@ -94,7 +96,7 @@ export function installReadSnapshot(current, snapshot) {
|
|
|
94
96
|
for (const previous of current.threads) {
|
|
95
97
|
const thread = byId.get(previous.id);
|
|
96
98
|
if (thread)
|
|
97
|
-
byId.set(thread.id, { ...thread, ...mergeDiscussionCursors(thread, previous), readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
|
|
99
|
+
byId.set(thread.id, { ...thread, ...mergeDiscussionCursors(thread, previous), preservedReadMessageIds: [...new Set([...(thread.preservedReadMessageIds ?? []), ...(previous.preservedReadMessageIds ?? [])])], readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
|
|
98
100
|
explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...previous.explicitReadMessageIds])] });
|
|
99
101
|
}
|
|
100
102
|
return resolveObservedReads({ ...snapshot, threads: [...byId.values()], pending, observedReadIntents: current.observedReadIntents ?? [] });
|
|
@@ -110,7 +112,7 @@ export function applyReadChange(current, change) {
|
|
|
110
112
|
const incoming = cloneThread(change.thread);
|
|
111
113
|
const pending = current.pending.filter(operation => operation.threadId !== incoming.id || !covered(incoming, operation));
|
|
112
114
|
const previous = current.threads.find(thread => thread.id === incoming.id);
|
|
113
|
-
const merged = previous ? { ...incoming, ...mergeDiscussionCursors(incoming, previous), readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
|
|
115
|
+
const merged = previous ? { ...incoming, ...mergeDiscussionCursors(incoming, previous), preservedReadMessageIds: [...new Set([...(incoming.preservedReadMessageIds ?? []), ...(previous.preservedReadMessageIds ?? [])])], readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
|
|
114
116
|
explicitReadMessageIds: [...new Set([...incoming.explicitReadMessageIds, ...previous.explicitReadMessageIds])] } : incoming;
|
|
115
117
|
const projected = pending.filter(operation => operation.threadId === incoming.id).reduce(applyOperation, merged);
|
|
116
118
|
return resolveObservedReads({ ...current, syncCursor: change.syncCursor, pending, threads: [...current.threads.filter(t => t.id !== incoming.id), projected] });
|
|
@@ -154,7 +156,7 @@ export function recordVisibleRead(current, threadId, messageId, operationId) {
|
|
|
154
156
|
const message = thread?.messages.find(m => m.id === messageId);
|
|
155
157
|
if (!thread || !message || message.readPolicy !== "visible")
|
|
156
158
|
throw new Error("INVALID_VISIBLE_READ");
|
|
157
|
-
if (message.seq <= readingCursor(thread, message.threadRootId))
|
|
159
|
+
if (thread.preservedReadMessageIds?.includes(message.id) || message.seq <= readingCursor(thread, message.threadRootId))
|
|
158
160
|
return current;
|
|
159
161
|
return enqueue(current, { operationId, threadId: thread.id, ...(message.threadRootId == null ? {} : { threadRootId: message.threadRootId }), readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
|
|
160
162
|
}
|
|
@@ -198,7 +200,7 @@ export function unreadInThread(state, threadId, hidden, threadRootId) {
|
|
|
198
200
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
199
201
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
200
202
|
const messages = thread.messages.filter(message => (threadRootId === undefined || message.threadRootId === threadRootId) && !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)
|
|
201
|
-
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > readingCursor(thread, message.threadRootId)));
|
|
203
|
+
&& !thread.preservedReadMessageIds?.includes(message.id) && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > readingCursor(thread, message.threadRootId)));
|
|
202
204
|
const unreadMessageCount = messages.length;
|
|
203
205
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
204
206
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
@@ -59,7 +59,7 @@ export class SocialReadClient {
|
|
|
59
59
|
const value = await this.request("message-threads", { threadId: input.threadId, ...this.teamQuery(input.teamId), rootMessageIds: JSON.stringify(input.rootMessageIds) }, signal);
|
|
60
60
|
const roots = new Set(input.rootMessageIds), seen = new Set();
|
|
61
61
|
if (!value || value.appId !== this.options.appId || value.threadId !== input.threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Array.isArray(value.items) || value.items.length !== roots.size || value.items.some(item => {
|
|
62
|
-
const invalid = !roots.has(item.rootMessageId) || seen.has(item.rootMessageId) || !Number.isSafeInteger(item.replyCount) || item.replyCount < 0 || !Number.isSafeInteger(item.unreadCount) || item.unreadCount < 0 || item.unreadCount > item.replyCount || !Array.isArray(item.participantProfileIds);
|
|
62
|
+
const invalid = item.ownerAgentId !== undefined && (typeof item.ownerAgentId !== "string" || !/^0x[a-f0-9]{64}$/.test(item.ownerAgentId)) || !roots.has(item.rootMessageId) || seen.has(item.rootMessageId) || !Number.isSafeInteger(item.replyCount) || item.replyCount < 0 || !Number.isSafeInteger(item.unreadCount) || item.unreadCount < 0 || item.unreadCount > item.replyCount || !Array.isArray(item.participantProfileIds);
|
|
63
63
|
seen.add(item.rootMessageId);
|
|
64
64
|
return invalid;
|
|
65
65
|
}))
|
|
@@ -67,7 +67,6 @@ export class SocialReadClient {
|
|
|
67
67
|
return value;
|
|
68
68
|
}
|
|
69
69
|
group(input, signal) {
|
|
70
|
-
this.requireTeam(input.teamId);
|
|
71
70
|
return this.request("group", { groupId: input.groupId, ...this.teamQuery(input.teamId) }, signal);
|
|
72
71
|
}
|
|
73
72
|
relationship(input) {
|
package/package.json
CHANGED