@pmate/chat-sdk 0.12.0-next.82 → 0.12.0-next.84

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 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
- const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
276
- const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
277
- return { ...this.group(group), threadId };
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))); }
@@ -109,7 +109,7 @@ export declare class ChatClient {
109
109
  teamId?: string;
110
110
  }): Promise<ChatRuntimeMessage>;
111
111
  createGroup(input: {
112
- groupId: string;
112
+ groupId?: string;
113
113
  teamId?: string;
114
114
  members: string[];
115
115
  title?: string;
@@ -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(input.groupId, {
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: input.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, {
@@ -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
+ }
@@ -20,5 +20,6 @@ export declare class ReadSyncClient {
20
20
  stop(): void;
21
21
  synchronize(): Promise<ReadIndexState>;
22
22
  private request;
23
+ private repairLegacyDiscussion;
23
24
  private run;
24
25
  }
@@ -39,8 +39,12 @@ export class ReadSyncClient {
39
39
  const response = await (this.options.fetch ?? globalThis.fetch)(`${this.options.hubHttpUrl.replace(/\/$/, "")}${path}`, {
40
40
  ...init, signal: timeout.signal, headers: { ...init.headers, authorization: `Bearer ${this.options.sessionToken}` },
41
41
  });
42
- if (!response.ok)
43
- throw Object.assign(new Error(`READ_SYNC_HTTP_${response.status}`), { status: response.status });
42
+ if (!response.ok) {
43
+ // Keep only a fixed domain code, never a provider response body.
44
+ const failure = await response.json().catch(() => undefined);
45
+ const code = failure?.message === "INVALID_READING_CURSOR" ? failure.message : undefined;
46
+ throw Object.assign(new Error(`READ_SYNC_HTTP_${response.status}`), { status: response.status, code });
47
+ }
44
48
  const envelope = await response.json();
45
49
  if (envelope.success !== true || envelope.data === undefined)
46
50
  throw new Error("INVALID_READ_SYNC_RESPONSE");
@@ -51,7 +55,42 @@ export class ReadSyncClient {
51
55
  this.controller.signal.removeEventListener("abort", abort);
52
56
  }
53
57
  }
54
- async run() {
58
+ async repairLegacyDiscussion(operation, versions) {
59
+ if (operation.threadRootId !== undefined || operation.readThroughSeq <= 0 || operation.explicitReadMessageIds.length)
60
+ return false;
61
+ const { appId, profileId, persistence } = this.options;
62
+ const query = new URLSearchParams({ appId, profileId, ...(this.options.metadataOnly ? { metadataOnly: "true" } : {}) });
63
+ const snapshot = await this.request(`/api/social/reading/snapshot?${query}`);
64
+ // Validate the complete response before changing any durable operation.
65
+ installReadSnapshot(createReadIndex(appId, profileId), snapshot);
66
+ const thread = snapshot.threads.find(thread => thread.id === operation.threadId);
67
+ const message = thread?.messages.find(message => message.id === operation.readThroughMessageId
68
+ && message.seq === operation.readThroughSeq && message.readPolicy === "visible" && message.threadRootId != null);
69
+ if (!thread || !message)
70
+ return false;
71
+ let repaired = false;
72
+ await persistence.update(appId, profileId, (state, currentVersions) => {
73
+ assertReadIndexVersions(versions, currentVersions);
74
+ if (this.controller.signal.aborted)
75
+ throw new Error("READ_SYNC_STOPPED");
76
+ const pending = state.pending.map(item => {
77
+ if (item.operationId !== operation.operationId || JSON.stringify(item) !== JSON.stringify(operation))
78
+ return item;
79
+ repaired = true;
80
+ // New payload, new identity: an operation ID must never change meaning.
81
+ return { ...item, operationId: crypto.randomUUID(), threadRootId: message.threadRootId };
82
+ });
83
+ if (!repaired)
84
+ return state;
85
+ // Undo the rejected main-conversation optimistic position, then replay all
86
+ // remaining operations on the authoritative snapshot in their own scopes.
87
+ const threads = state.threads.map(item => item.id === thread.id && item.readThroughSeq === operation.readThroughSeq
88
+ ? { ...item, readThroughSeq: thread.readThroughSeq } : item);
89
+ return installReadSnapshot({ ...state, threads, pending }, snapshot);
90
+ });
91
+ return repaired;
92
+ }
93
+ async run(repairedLegacy = false) {
55
94
  const { appId, profileId, persistence } = this.options;
56
95
  const loaded = persistence.loadVersioned ? await persistence.loadVersioned(appId, profileId) : { state: await persistence.load(appId, profileId), versions: {} };
57
96
  const current = loaded.state ?? createReadIndex(appId, profileId);
@@ -72,9 +111,19 @@ export class ReadSyncClient {
72
111
  for (const operation of outgoing) {
73
112
  if (persistence.loadVersions)
74
113
  assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
75
- const receipt = await this.request("/api/social/reading", {
76
- method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
77
- });
114
+ let receipt;
115
+ try {
116
+ receipt = await this.request("/api/social/reading", {
117
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...operation, appId, profileId }),
118
+ });
119
+ }
120
+ catch (error) {
121
+ if (!repairedLegacy && error?.status === 400
122
+ && error.code === "INVALID_READING_CURSOR"
123
+ && await this.repairLegacyDiscussion(operation, versions))
124
+ return this.run(true);
125
+ throw error;
126
+ }
78
127
  if (receipt.operationId !== operation.operationId || receipt.accepted !== true
79
128
  || !Number.isSafeInteger(receipt.acceptedAt) || receipt.acceptedAt <= 0)
80
129
  throw new Error("INVALID_READING_RECEIPT");
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.82",
3
+ "version": "0.12.0-next.84",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",