@peers-app/peers-sdk 0.27.0 → 0.27.1

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.
@@ -51,7 +51,7 @@ async function createGroup(userContext, input) {
51
51
  });
52
52
  await (0, groups_1.Groups)(groupDataContext).signAndSave(group);
53
53
  await (0, group_members_1.GroupMembers)(groupDataContext).signAndSave({
54
- groupMemberId: (0, utils_1.newid)(),
54
+ groupMemberId: (0, utils_1.deterministicGroupMemberId)(groupId, me.userId),
55
55
  groupId,
56
56
  userId: me.userId,
57
57
  role: group_member_roles_1.GroupMemberRole.Founder,
@@ -36,6 +36,20 @@ export declare class GroupMembersTable extends Table<IGroupMember> {
36
36
  save(groupMember: IGroupMember, opts?: ISaveOptions): Promise<IGroupMember>;
37
37
  signAndSave(groupMember: IGroupMember, opts?: ISaveOptions): Promise<IGroupMember>;
38
38
  delete(groupMemberId: string): Promise<void>;
39
+ /**
40
+ * Settle two membership rows for the same user in the same group: devices that approved the
41
+ * same join at once could each write one, under a different id.
42
+ *
43
+ * Merged only when both rows grant the same access (role and deleted state), so nothing about
44
+ * the member's access changes. The id {@link deterministicGroupMemberId} gives wins if either
45
+ * row has it, otherwise the lexicographically smaller id; the winner is kept as it is, signature
46
+ * included. Rows that disagree stay queued for a person. A pure function of the two rows, so
47
+ * every device keeps the same one.
48
+ */
49
+ resolveUniqueConflict(incoming: IGroupMember, existing: IGroupMember): {
50
+ winner: IGroupMember;
51
+ loserId: string;
52
+ } | undefined;
39
53
  /** Registers the signer for all SDK copies in this JavaScript realm. Replaces any prior signer. */
40
54
  static enableGroupMemberSigning(fn: (groupMember: IGroupMember) => IGroupMember): void;
41
55
  /** @deprecated Forbidden on GroupsTable; use save() */
@@ -40,6 +40,7 @@ const zod_1 = require("zod");
40
40
  const user_context_singleton_1 = require("../context/user-context-singleton");
41
41
  const decorators_1 = require("../data/orm/decorators");
42
42
  const zod_types_1 = require("../types/zod-types");
43
+ const utils_1 = require("../utils");
43
44
  const group_member_roles_1 = require("./group-member-roles");
44
45
  const group_permissions_1 = require("./group-permissions");
45
46
  const table_1 = require("./orm/table");
@@ -125,6 +126,41 @@ let GroupMembersTable = (() => {
125
126
  await this.signAndSave(groupMember);
126
127
  }
127
128
  }
129
+ /**
130
+ * Settle two membership rows for the same user in the same group: devices that approved the
131
+ * same join at once could each write one, under a different id.
132
+ *
133
+ * Merged only when both rows grant the same access (role and deleted state), so nothing about
134
+ * the member's access changes. The id {@link deterministicGroupMemberId} gives wins if either
135
+ * row has it, otherwise the lexicographically smaller id; the winner is kept as it is, signature
136
+ * included. Rows that disagree stay queued for a person. A pure function of the two rows, so
137
+ * every device keeps the same one.
138
+ */
139
+ resolveUniqueConflict(incoming, existing) {
140
+ if (incoming.groupId !== existing.groupId || incoming.userId !== existing.userId) {
141
+ return undefined;
142
+ }
143
+ if (incoming.groupMemberId === existing.groupMemberId) {
144
+ return undefined;
145
+ }
146
+ if (incoming.role !== existing.role ||
147
+ Boolean(incoming.deleted) !== Boolean(existing.deleted)) {
148
+ return undefined;
149
+ }
150
+ const derivedId = (0, utils_1.deterministicGroupMemberId)(incoming.groupId, incoming.userId);
151
+ let winner;
152
+ if (incoming.groupMemberId === derivedId) {
153
+ winner = incoming;
154
+ }
155
+ else if (existing.groupMemberId === derivedId) {
156
+ winner = existing;
157
+ }
158
+ else {
159
+ winner = incoming.groupMemberId < existing.groupMemberId ? incoming : existing;
160
+ }
161
+ const loser = winner === incoming ? existing : incoming;
162
+ return { winner, loserId: loser.groupMemberId };
163
+ }
128
164
  /** Registers the signer for all SDK copies in this JavaScript realm. Replaces any prior signer. */
129
165
  static enableGroupMemberSigning(fn) {
130
166
  signingGlobals[signingKey] = fn;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const keys_1 = require("../keys");
4
+ const utils_1 = require("../utils");
5
+ const group_members_1 = require("./group-members");
4
6
  const group_permissions_1 = require("./group-permissions");
5
7
  // Test the group members signature logic directly without ORM dependencies
6
8
  describe("Group Members Signature Logic", () => {
@@ -285,3 +287,50 @@ describe("Group Members Signature Logic", () => {
285
287
  });
286
288
  });
287
289
  });
290
+ describe("GroupMembersTable.resolveUniqueConflict", () => {
291
+ const groupId = (0, utils_1.newid)();
292
+ const userId = (0, utils_1.newid)();
293
+ const resolve = (incoming, existing) => group_members_1.GroupMembersTable.prototype.resolveUniqueConflict.call({}, incoming, existing);
294
+ const row = (groupMemberId, overrides = {}) => ({
295
+ groupMemberId,
296
+ groupId,
297
+ userId,
298
+ role: group_permissions_1.GroupMemberRole.Writer,
299
+ signature: `sig-${groupMemberId}`,
300
+ ...overrides,
301
+ });
302
+ it("keeps the smaller id, unchanged, whichever row arrives", () => {
303
+ const [low, high] = [(0, utils_1.newid)(), (0, utils_1.newid)()].sort();
304
+ const a = row(low);
305
+ const b = row(high);
306
+ expect(resolve(a, b)).toEqual({ winner: a, loserId: high });
307
+ expect(resolve(b, a)).toEqual({ winner: a, loserId: high });
308
+ expect(resolve(b, a)?.winner.signature).toBe(`sig-${low}`);
309
+ });
310
+ it("prefers the id derived from the group and user", () => {
311
+ const derived = row((0, utils_1.deterministicGroupMemberId)(groupId, userId));
312
+ const other = row("00000000000000000000000001");
313
+ expect(resolve(other, derived)).toEqual({ winner: derived, loserId: other.groupMemberId });
314
+ expect(resolve(derived, other)).toEqual({ winner: derived, loserId: other.groupMemberId });
315
+ });
316
+ it("leaves rows that grant different access for a person to settle", () => {
317
+ const a = row((0, utils_1.newid)());
318
+ expect(resolve(a, row((0, utils_1.newid)(), { role: group_permissions_1.GroupMemberRole.Admin }))).toBeUndefined();
319
+ expect(resolve(a, row((0, utils_1.newid)(), { deleted: true }))).toBeUndefined();
320
+ expect(resolve(a, row((0, utils_1.newid)(), { userId: (0, utils_1.newid)() }))).toBeUndefined();
321
+ expect(resolve(a, row((0, utils_1.newid)(), { groupId: (0, utils_1.newid)() }))).toBeUndefined();
322
+ expect(resolve(a, { ...a })).toBeUndefined();
323
+ });
324
+ it("treats a missing deleted flag as not deleted", () => {
325
+ const a = row((0, utils_1.newid)());
326
+ const b = row((0, utils_1.newid)(), { deleted: false });
327
+ expect(resolve(a, b)).toBeDefined();
328
+ });
329
+ it("depends only on the two signed rows and never modifies either one", () => {
330
+ const a = Object.freeze(row((0, utils_1.newid)()));
331
+ const b = Object.freeze(row((0, utils_1.newid)()));
332
+ const before = [structuredClone(a), structuredClone(b)];
333
+ expect(resolve(a, b)).toEqual(resolve(b, a));
334
+ expect([a, b]).toEqual(before);
335
+ });
336
+ });
@@ -31,7 +31,7 @@ async function buildGroupApproval(userContext, input) {
31
31
  // Groups created before founders got an explicit membership row: the founder role is
32
32
  // implicit (see `getUserRole`). Write the row now so it seeds to joiners like any admin.
33
33
  approvingMember = await groupMembersTable.signAndSave({
34
- groupMemberId: (0, utils_1.newid)(),
34
+ groupMemberId: (0, utils_1.deterministicGroupMemberId)(input.groupId, userContext.userId),
35
35
  groupId: input.groupId,
36
36
  userId: userContext.userId,
37
37
  role: data_1.GroupMemberRole.Founder,
@@ -65,8 +65,10 @@ async function buildGroupApproval(userContext, input) {
65
65
  membership = await groupMembersTable.signAndSave(existingMember);
66
66
  }
67
67
  else {
68
+ // The same id on each of the approver's devices: a join several of them approve at once
69
+ // is one row, not rows that collide on (groupId, userId).
68
70
  membership = await groupMembersTable.signAndSave({
69
- groupMemberId: (0, utils_1.newid)(),
71
+ groupMemberId: (0, utils_1.deterministicGroupMemberId)(input.groupId, input.joiner.userId),
70
72
  groupId: input.groupId,
71
73
  userId: input.joiner.userId,
72
74
  role: input.role,
@@ -149,6 +149,8 @@ describe("group approval payloads", () => {
149
149
  role: group_member_roles_1.GroupMemberRole.Writer,
150
150
  });
151
151
  expect(approval.membership.signature).toContain(":");
152
+ // Each of the approver's devices writes the same row for this join.
153
+ expect(approval.membership.groupMemberId).toBe((0, utils_1.deterministicGroupMemberId)(group.groupId, joiner.userId));
152
154
  expect(approval.seed.groupMembers.map((member) => member.userId)).toContain(admin.userId);
153
155
  expect(approval.seed.devices[0]).toMatchObject({
154
156
  deviceId: admin.deviceId,
package/dist/utils.d.ts CHANGED
@@ -37,6 +37,23 @@ export declare function deterministicTableId(identity: string): string;
37
37
  * @param id Peer ID to inspect.
38
38
  */
39
39
  export declare function isDeterministicTableId(id: string): boolean;
40
+ /**
41
+ * The ID of a user's membership row in a group, the same on every device.
42
+ *
43
+ * Several of an admin's devices can approve the same join at once; with this ID they write one
44
+ * row, which merges as an ordinary update, instead of rows that collide on the
45
+ * `(groupId, userId)` unique index.
46
+ *
47
+ * @param groupId The group.
48
+ * @param userId The member.
49
+ */
50
+ export declare function deterministicGroupMemberId(groupId: string, userId: string): string;
51
+ /**
52
+ * Return whether a peer ID was generated by {@link deterministicGroupMemberId}.
53
+ *
54
+ * @param id Peer ID to inspect.
55
+ */
56
+ export declare function isDeterministicGroupMemberId(id: string): boolean;
40
57
  /** Resolves after `ms` milliseconds (0 yields a microtask-yield on most runtimes). */
41
58
  export declare function sleep(ms?: number): Promise<void>;
42
59
  /** Turns `fooBar` into `Foo Bar` for labels (also replaces `_` with space). */
@@ -93,6 +110,8 @@ export declare function getTimestamp(): number;
93
110
  * @param opts.timeout Clamped timeout; defaults to {@link PeerDeviceConsts} min/max
94
111
  * @param opts.retriesOnError Max error retries
95
112
  * @param opts.retriesOnTimeout Max “timed out” races before giving up
113
+ * @param opts.shouldRetry Return false for a terminal error that must be reported immediately.
114
+ * @param opts.logFailure Log exhausted error retries; defaults to `console.error`.
96
115
  * @throws Last error, timeout exhaustion, or if the connection is closed
97
116
  */
98
117
  export declare function retryOnErrorOrTimeout<T>({ fn, connection, opts, }: {
@@ -102,6 +121,8 @@ export declare function retryOnErrorOrTimeout<T>({ fn, connection, opts, }: {
102
121
  timeout?: number;
103
122
  retriesOnError?: number;
104
123
  retriesOnTimeout?: number;
124
+ shouldRetry?: (error: unknown) => boolean;
125
+ logFailure?: (message: string, error: unknown) => void;
105
126
  };
106
127
  }): Promise<T>;
107
128
  /**
package/dist/utils.js CHANGED
@@ -7,6 +7,8 @@ exports.deterministicPvarId = deterministicPvarId;
7
7
  exports.isDeterministicPvarId = isDeterministicPvarId;
8
8
  exports.deterministicTableId = deterministicTableId;
9
9
  exports.isDeterministicTableId = isDeterministicTableId;
10
+ exports.deterministicGroupMemberId = deterministicGroupMemberId;
11
+ exports.isDeterministicGroupMemberId = isDeterministicGroupMemberId;
10
12
  exports.sleep = sleep;
11
13
  exports.camelCaseToSpaces = camelCaseToSpaces;
12
14
  exports.camelCaseToHyphens = camelCaseToHyphens;
@@ -48,6 +50,8 @@ function simpleObjectHash(obj) {
48
50
  const DETERMINISTIC_PVAR_PREFIX = "000pvar0";
49
51
  /** Well-known prefix for deterministic package-table IDs. */
50
52
  const DETERMINISTIC_TABLE_PREFIX = "000tbl00";
53
+ /** Well-known prefix for deterministic group-membership IDs. */
54
+ const DETERMINISTIC_GROUP_MEMBER_PREFIX = "000gmbr0";
51
55
  /**
52
56
  * Converts a byte array to a base-36 string of the requested length, using unbiased modular reduction.
53
57
  * Bytes >= 252 are skipped to avoid bias (same technique as {@link cryptoRandomString}).
@@ -105,6 +109,27 @@ function deterministicTableId(identity) {
105
109
  function isDeterministicTableId(id) {
106
110
  return id.startsWith(DETERMINISTIC_TABLE_PREFIX);
107
111
  }
112
+ /**
113
+ * The ID of a user's membership row in a group, the same on every device.
114
+ *
115
+ * Several of an admin's devices can approve the same join at once; with this ID they write one
116
+ * row, which merges as an ordinary update, instead of rows that collide on the
117
+ * `(groupId, userId)` unique index.
118
+ *
119
+ * @param groupId The group.
120
+ * @param userId The member.
121
+ */
122
+ function deterministicGroupMemberId(groupId, userId) {
123
+ return deterministicPeerId(DETERMINISTIC_GROUP_MEMBER_PREFIX, `${groupId}:${userId}`);
124
+ }
125
+ /**
126
+ * Return whether a peer ID was generated by {@link deterministicGroupMemberId}.
127
+ *
128
+ * @param id Peer ID to inspect.
129
+ */
130
+ function isDeterministicGroupMemberId(id) {
131
+ return id.startsWith(DETERMINISTIC_GROUP_MEMBER_PREFIX);
132
+ }
108
133
  /** Resolves after `ms` milliseconds (0 yields a microtask-yield on most runtimes). */
109
134
  function sleep(ms = 0) {
110
135
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -243,6 +268,8 @@ function getTimestamp() {
243
268
  * @param opts.timeout Clamped timeout; defaults to {@link PeerDeviceConsts} min/max
244
269
  * @param opts.retriesOnError Max error retries
245
270
  * @param opts.retriesOnTimeout Max “timed out” races before giving up
271
+ * @param opts.shouldRetry Return false for a terminal error that must be reported immediately.
272
+ * @param opts.logFailure Log exhausted error retries; defaults to `console.error`.
246
273
  * @throws Last error, timeout exhaustion, or if the connection is closed
247
274
  */
248
275
  async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
@@ -258,8 +285,8 @@ async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
258
285
  let resultSet = false;
259
286
  let finalError;
260
287
  while (errorCount <= retriesOnError && timeoutCount <= retriesOnTimeout) {
288
+ let sleepTimeout = null;
261
289
  try {
262
- let sleepTimeout = null;
263
290
  const sleepPromise = new Promise((resolve) => {
264
291
  sleepTimeout = setTimeout(() => {
265
292
  resolve();
@@ -292,14 +319,20 @@ async function retryOnErrorOrTimeout({ fn, connection, opts = {}, }) {
292
319
  finalError = error;
293
320
  connection.errorRate = connection.errorRate * 0.9 + 0.1;
294
321
  errorCount++;
322
+ if (opts.shouldRetry?.(error) === false)
323
+ throw error;
295
324
  if (connection.closed) {
296
325
  throw new Error(`connection has been closed`);
297
326
  }
298
327
  }
328
+ finally {
329
+ if (sleepTimeout !== null)
330
+ clearTimeout(sleepTimeout);
331
+ }
299
332
  console.warn(`Call failed, retrying: timeout is ${timeout}ms, errorCount: ${errorCount} of ${retriesOnError}, timeoutCount: ${timeoutCount} of ${retriesOnTimeout + 1}. Last error:`, finalError);
300
333
  }
301
334
  if (finalError) {
302
- console.error(`Retry failed after ${errorCount} errors and ${timeoutCount} timeouts. Last error:`, finalError);
335
+ (opts.logFailure ?? console.error)(`Retry failed after ${errorCount} errors and ${timeoutCount} timeouts. Last error:`, finalError);
303
336
  throw finalError;
304
337
  }
305
338
  else {
@@ -2,6 +2,25 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const lodash_1 = require("lodash");
4
4
  const utils_1 = require("./utils");
5
+ describe("retryOnErrorOrTimeout", () => {
6
+ it("reports a terminal error after one call and clears its timeout", async () => {
7
+ jest.useFakeTimers();
8
+ const error = new Error("No handler registered for event: listChanges_group");
9
+ const fn = jest.fn().mockRejectedValue(error);
10
+ try {
11
+ await expect((0, utils_1.retryOnErrorOrTimeout)({
12
+ fn,
13
+ connection: { deviceId: (0, utils_1.newid)(), latencyMs: 1, errorRate: 0, timestampLastApplied: 0 },
14
+ opts: { shouldRetry: () => false },
15
+ })).rejects.toBe(error);
16
+ expect(fn).toHaveBeenCalledTimes(1);
17
+ expect(jest.getTimerCount()).toBe(0);
18
+ }
19
+ finally {
20
+ jest.useRealTimers();
21
+ }
22
+ });
23
+ });
5
24
  describe("readResponseTextCapped", () => {
6
25
  it("returns a body within the cap and refuses one over it", async () => {
7
26
  await expect((0, utils_1.readResponseTextCapped)(new Response("hello"), 5)).resolves.toBe("hello");
@@ -132,6 +151,23 @@ describe("newid", () => {
132
151
  });
133
152
  });
134
153
  });
154
+ describe("deterministicGroupMemberId", () => {
155
+ it("preserves the membership identity protocol across SDK versions", () => {
156
+ expect((0, utils_1.deterministicGroupMemberId)("00000000000000000000000001", "00000000000000000000000002")).toBe("000gmbr0jzkpw7vuc2ew84u8c");
157
+ });
158
+ it("is a stable peer id per group and user", () => {
159
+ const groupId = (0, utils_1.newid)();
160
+ const userId = (0, utils_1.newid)();
161
+ const id = (0, utils_1.deterministicGroupMemberId)(groupId, userId);
162
+ expect(id).toMatch(/^[a-z0-9]{25}$/);
163
+ expect((0, utils_1.isid)(id)).toBe(true);
164
+ expect((0, utils_1.isDeterministicGroupMemberId)(id)).toBe(true);
165
+ expect((0, utils_1.deterministicGroupMemberId)(groupId, userId)).toBe(id);
166
+ expect((0, utils_1.deterministicGroupMemberId)(groupId, (0, utils_1.newid)())).not.toBe(id);
167
+ expect((0, utils_1.deterministicGroupMemberId)(userId, groupId)).not.toBe(id);
168
+ expect((0, utils_1.isDeterministicGroupMemberId)((0, utils_1.newid)())).toBe(false);
169
+ });
170
+ });
135
171
  describe("debounceByArgs", () => {
136
172
  beforeEach(() => {
137
173
  jest.useFakeTimers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"