@peers-app/peers-sdk 0.23.1 → 0.23.4

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.
@@ -1,3 +1,4 @@
1
+ import type { UserContext } from "../context/user-context";
1
2
  import { type IGroupMember } from "./group-members";
2
3
  import { type IGroup } from "./groups";
3
4
  /**
@@ -16,6 +17,18 @@ export interface IGroupShare {
16
17
  * @throws Error if group not found or user lacks permission
17
18
  */
18
19
  export declare function generateGroupShare(groupId: string): Promise<IGroupShare>;
20
+ /**
21
+ * Write a group record and missing membership rows into the group's data context.
22
+ *
23
+ * Uses the lowest data source so bootstrap can seed already-signed rows without
24
+ * re-running table permission checks. Existing memberships for the same user
25
+ * are left in place so retries can repair a partial import.
26
+ *
27
+ * @param userContext - Joiner or importer whose group context should be seeded
28
+ * @param groupShare - Canonical group plus Admin+ memberships
29
+ * @param extraMembers - Additional signed rows such as a newly approved Reader
30
+ */
31
+ export declare function seedGroupContextFromShare(userContext: UserContext, groupShare: Pick<IGroupShare, "group" | "groupMembers">, extraMembers?: IGroupMember[]): Promise<void>;
19
32
  /**
20
33
  * Validates that an object has the correct GroupShare structure
21
34
  * @param obj Object to validate
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateGroupShare = generateGroupShare;
4
+ exports.seedGroupContextFromShare = seedGroupContextFromShare;
4
5
  exports.validateGroupShare = validateGroupShare;
5
6
  exports.estimateGroupShareSize = estimateGroupShareSize;
6
7
  exports.checkQRCodeCompatibility = checkQRCodeCompatibility;
@@ -57,7 +58,7 @@ async function importGroupShare(groupShareJson) {
57
58
  throw new Error("Invalid GroupShare structure");
58
59
  }
59
60
  const userContext = await (0, user_context_singleton_1.getUserContext)();
60
- const { group, groupMembers } = groupShare;
61
+ const { group } = groupShare;
61
62
  // Check if group already exists
62
63
  const existingGroup = await (0, groups_1.Groups)(userContext.userDataContext).get(group.groupId);
63
64
  if (existingGroup) {
@@ -65,19 +66,42 @@ async function importGroupShare(groupShareJson) {
65
66
  }
66
67
  // Save group to user's groups table
67
68
  await (0, groups_1.Groups)(userContext.userDataContext).save(group, { restoreIfDeleted: true });
68
- // Get group data context for the new group
69
+ await seedGroupContextFromShare(userContext, groupShare);
70
+ return group.groupId;
71
+ }
72
+ /**
73
+ * Write a group record and missing membership rows into the group's data context.
74
+ *
75
+ * Uses the lowest data source so bootstrap can seed already-signed rows without
76
+ * re-running table permission checks. Existing memberships for the same user
77
+ * are left in place so retries can repair a partial import.
78
+ *
79
+ * @param userContext - Joiner or importer whose group context should be seeded
80
+ * @param groupShare - Canonical group plus Admin+ memberships
81
+ * @param extraMembers - Additional signed rows such as a newly approved Reader
82
+ */
83
+ async function seedGroupContextFromShare(userContext, groupShare, extraMembers = []) {
84
+ const { group, groupMembers } = groupShare;
69
85
  const groupDataContext = userContext.getDataContext(group.groupId);
70
- // Save the group data to the group's own context
71
86
  const groupsTable = (0, groups_1.Groups)(groupDataContext);
72
- const groupsTableDataSource = getLowestDataSource(groupsTable);
73
- await groupsTableDataSource.save(group);
74
- // Save all admin members as seed data for device recognition
87
+ await getLowestDataSource(groupsTable).save(group);
75
88
  const groupMembersTable = (0, group_members_1.GroupMembers)(groupDataContext);
76
89
  const groupMembersDataSource = getLowestDataSource(groupMembersTable);
77
- for (const member of groupMembers) {
90
+ const seenUserIds = new Set();
91
+ for (const member of [...groupMembers, ...extraMembers]) {
92
+ if (!member?.userId || member.groupId !== group.groupId || seenUserIds.has(member.userId)) {
93
+ continue;
94
+ }
95
+ seenUserIds.add(member.userId);
96
+ const existing = await groupMembersTable.findOne({
97
+ groupId: group.groupId,
98
+ userId: member.userId,
99
+ });
100
+ if (existing) {
101
+ continue;
102
+ }
78
103
  await groupMembersDataSource.save(member);
79
104
  }
80
- return group.groupId;
81
105
  }
82
106
  /**
83
107
  * Validates that an object has the correct GroupShare structure
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const SQLiteDB = require("better-sqlite3");
4
+ const user_context_1 = require("../context/user-context");
5
+ const utils_1 = require("../utils");
6
+ const group_member_roles_1 = require("./group-member-roles");
7
+ const group_members_1 = require("./group-members");
8
+ const group_share_1 = require("./group-share");
9
+ const groups_1 = require("./groups");
10
+ const sql_data_source_1 = require("./orm/sql.data-source");
11
+ class DBHarness {
12
+ _db = null;
13
+ get db() {
14
+ if (!this._db) {
15
+ this._db = new SQLiteDB(":memory:");
16
+ this._db.pragma("journal_mode = WAL");
17
+ }
18
+ return this._db;
19
+ }
20
+ async get(sql, params = []) {
21
+ return this.db.prepare(sql).get(params);
22
+ }
23
+ async all(sql, params = []) {
24
+ return this.db.prepare(sql).all(params);
25
+ }
26
+ async exec(sql, params = []) {
27
+ const result = this.db.prepare(sql).run(params);
28
+ return { changes: result.changes };
29
+ }
30
+ async close() {
31
+ this._db?.close();
32
+ this._db = null;
33
+ }
34
+ }
35
+ describe("seedGroupContextFromShare", () => {
36
+ const dbs = new Map();
37
+ let userContext;
38
+ const userId = (0, utils_1.newid)();
39
+ beforeAll(async () => {
40
+ groups_1.GroupsTable.isPassthrough = true;
41
+ group_members_1.GroupMembersTable.isPassthrough = true;
42
+ const dataSourceFactory = (metaData, schema, groupId) => {
43
+ const key = groupId || userId;
44
+ let db = dbs.get(key);
45
+ if (!db) {
46
+ db = new DBHarness();
47
+ dbs.set(key, db);
48
+ }
49
+ return new sql_data_source_1.SQLDataSource(db, metaData, schema);
50
+ };
51
+ userContext = new user_context_1.UserContext(userId, dataSourceFactory, true);
52
+ await userContext.loadingPromise;
53
+ userContext.deviceId((0, utils_1.newid)());
54
+ });
55
+ afterAll(async () => {
56
+ groups_1.GroupsTable.isPassthrough = false;
57
+ group_members_1.GroupMembersTable.isPassthrough = false;
58
+ userContext.dispose();
59
+ await Promise.all([...dbs.values()].map((db) => db.close()));
60
+ });
61
+ function makeGroup() {
62
+ return {
63
+ groupId: (0, utils_1.newid)(),
64
+ name: "Home",
65
+ description: "",
66
+ founderUserId: (0, utils_1.newid)(),
67
+ signature: "group-sig",
68
+ publicKey: "pk",
69
+ publicBoxKey: "pbk",
70
+ };
71
+ }
72
+ function makeMember(groupId, memberUserId, role) {
73
+ return {
74
+ groupMemberId: (0, utils_1.newid)(),
75
+ groupId,
76
+ userId: memberUserId,
77
+ role,
78
+ signature: "member-sig",
79
+ };
80
+ }
81
+ it("seeds the group record and missing memberships, including extra Reader rows", async () => {
82
+ const group = makeGroup();
83
+ const admin = makeMember(group.groupId, (0, utils_1.newid)(), group_member_roles_1.GroupMemberRole.Admin);
84
+ const joiner = makeMember(group.groupId, userId, group_member_roles_1.GroupMemberRole.Reader);
85
+ await (0, group_share_1.seedGroupContextFromShare)(userContext, { group, groupMembers: [admin] }, [joiner]);
86
+ const groupContext = userContext.getDataContext(group.groupId);
87
+ await expect((0, groups_1.Groups)(groupContext).get(group.groupId)).resolves.toMatchObject({
88
+ name: "Home",
89
+ });
90
+ await expect((0, group_members_1.GroupMembers)(groupContext).findOne({ userId: admin.userId, groupId: group.groupId })).resolves.toMatchObject({ role: group_member_roles_1.GroupMemberRole.Admin });
91
+ await expect((0, group_members_1.GroupMembers)(groupContext).findOne({ userId: userId, groupId: group.groupId })).resolves.toMatchObject({ role: group_member_roles_1.GroupMemberRole.Reader });
92
+ });
93
+ it("leaves existing memberships in place on a retry", async () => {
94
+ const group = makeGroup();
95
+ const admin = makeMember(group.groupId, (0, utils_1.newid)(), group_member_roles_1.GroupMemberRole.Admin);
96
+ await (0, group_share_1.seedGroupContextFromShare)(userContext, { group, groupMembers: [admin] });
97
+ const replacement = {
98
+ ...admin,
99
+ groupMemberId: (0, utils_1.newid)(),
100
+ signature: "other-sig",
101
+ };
102
+ await (0, group_share_1.seedGroupContextFromShare)(userContext, { group, groupMembers: [replacement] });
103
+ const saved = await (0, group_members_1.GroupMembers)(userContext.getDataContext(group.groupId)).findOne({
104
+ userId: admin.userId,
105
+ groupId: group.groupId,
106
+ });
107
+ expect(saved?.groupMemberId).toBe(admin.groupMemberId);
108
+ expect(saved?.signature).toBe("member-sig");
109
+ });
110
+ });
@@ -5,6 +5,8 @@
5
5
  * password. Multiple admins can listen for the same password simultaneously.
6
6
  */
7
7
  import type { GroupMemberRole } from "../data/group-member-roles";
8
+ import type { IGroupMember } from "../data/group-members";
9
+ import type { IGroupShare } from "../data/group-share";
8
10
  /**
9
11
  * Information about a group shared during the invitation flow.
10
12
  */
@@ -69,16 +71,24 @@ export interface IGroupJoinApprovalInfo {
69
71
  approved: boolean;
70
72
  /** The role assigned to the new member (if approved) */
71
73
  role?: GroupMemberRole;
72
- /** Group share data for initial sync (if approved) */
73
- groupShare?: {
74
- group: any;
75
- groupMembers: any[];
76
- };
74
+ /** Canonical group record plus Admin+ seed memberships */
75
+ groupShare?: IGroupShare;
76
+ /** Newly signed membership for the joiner, including Reader roles */
77
+ newMember?: IGroupMember;
77
78
  /** Admin's user info for adding to contacts */
78
79
  adminInfo?: IGroupJoinRequestInfo;
79
80
  /** Error message if rejected */
80
81
  rejectionReason?: string;
81
82
  }
83
+ /**
84
+ * Signal that a joiner finished importing approval data and can connect to the admin.
85
+ */
86
+ export interface IGroupInviteJoinFinished {
87
+ /** Group the joiner just imported */
88
+ groupId: string;
89
+ /** Approving admin device that should be contacted in that group context */
90
+ adminDeviceId: string;
91
+ }
82
92
  /**
83
93
  * Active invitation listener state.
84
94
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-sdk",
3
- "version": "0.23.1",
3
+ "version": "0.23.4",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-sdk.git"